Frameworks and agents · Published 2026-10-31 · Updated 2026-10-31 · AgentiSend
Send email with Phoenix
A Phoenix controller action that posts one booking notice through :httpc, which ships with Erlang.
To send email with Phoenix, call one function from a controller action that posts JSON to POST /emails. The controllers guide says an action is a function of conn and params, that the router names it as post "/send", ReceiptController, :create, and that json/2 renders a map. There is no Elixir package for this API, and this page does not invent one. :httpc ships with Erlang. Send email with Elixir is that client with no controller. The function below checks the address, sends a booking with an idempotency key derived from the guest, and returns the message id or the refusal's code and fix. The suite runs elixir send.exs against a local AgentiSend server. It does not boot Phoenix.
Install
No package to add. The function uses :httpc. The action renders with json/2:
def create(conn, %{"email" => email, "note" => note}) do
{status, body} = ReceiptMail.send(email, note)
conn |> put_status(status) |> json(body)
endEnvironment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
The route
# Phoenix — the function a controller action calls.
#
# There is no Elixir package for this API. :httpc ships with Erlang. A
# Phoenix action reads params and passes email and note here, then renders
# the map with json/2. This file does not boot Phoenix.
#
# AGENTISEND_API_KEY is the key. MAIL_FROM is an address on a domain you have
# verified. AGENTISEND_BASE_URL is optional and defaults to the public API.
#
# Run: elixir send.exs
:inets.start()
:ssl.start()
defmodule ReceiptMail do
@address ~r/^[^@\s]+@[^@\s]+\.[^@\s]+$/
def send(email, note) do
email = String.trim(email)
if Regex.match?(@address, email) do
post(email, note)
else
{400, %{"error" => "email must be one address, like you@example.com"}}
end
end
defp env!(name) do
case System.get_env(name) do
nil -> raise "Set #{name}."
"" -> raise "Set #{name}."
value -> value
end
end
defp base_url do
case System.get_env("AGENTISEND_BASE_URL") do
nil -> "https://api.agentisend.com"
"" -> "https://api.agentisend.com"
value -> String.trim_trailing(value, "/")
end
end
defp json_string(raw, key) do
case Regex.run(~r/"#{key}"\s*:\s*"([^"]*)"/, raw) do
[_, value] -> value
_ -> nil
end
end
defp post(email, note) do
from = env!("MAIL_FROM")
payload =
~s({"from":"#{from}","to":"#{email}","subject":"Your table is booked","text":"Booking note: #{note}"})
headers = [
{'authorization', String.to_charlist("Bearer " <> env!("AGENTISEND_API_KEY"))},
{'accept', 'application/json'},
# The guest, not the moment: a retry of the same note replays.
{'idempotency-key', String.to_charlist("table/" <> email)}
]
request =
{String.to_charlist(base_url() <> "/emails"), headers, 'application/json',
String.to_charlist(payload)}
case :httpc.request(:post, request, [{:timeout, 30_000}], []) do
{:ok, {{_, status, _}, _, body}} ->
text = to_string(body)
if status >= 400 do
{status, %{"code" => json_string(text, "code"), "fix" => json_string(text, "fix")}}
else
{status, %{"id" => json_string(text, "id")}}
end
other ->
raise "request failed: #{inspect(other)}"
end
end
end
defmodule Emit do
def line(step, {status, fields}) do
pairs =
[~s("step":"#{step}"), ~s("status":#{status})] ++
Enum.flat_map(fields, fn
{_key, nil} -> []
{key, value} -> [~s("#{key}":"#{value}")]
end)
IO.puts("{" <> Enum.join(pairs, ",") <> "}")
end
end
Emit.line("send", ReceiptMail.send("phoenix@example.com", "first"))
Emit.line("retry", ReceiptMail.send("phoenix@example.com", "first"))
Emit.line("invalid", ReceiptMail.send("not-an-address", "first"))
Emit.line("refused", ReceiptMail.send("phoenix@example.com", "second"))Try it
elixir send.exsThe script prints one JSON object per step: the booking, the same request again, a malformed address answered before any request, and a changed note under the same key.
What comes back when it goes wrong
Every refused request carries code, message, fix and docs_url. The function returns code and fix with the API's status, and put_status passes that status through. The error catalogue lists every code.