A small API, by design
Hello, inbox.
Everything you need to send your first message through Fabrica.
1. Connect your space
Sign in with Citadel and create a space. Under Email settings, add your Postmark
server API token
and message stream (usually outbound). Your sender address or domain must be verified in Postmark.
Create an application in that space. Copy its one-time token into FABRICA_TOKEN. Each application has its own token; it can be rotated or paused in the console.
2. Send an email
Use POST /api/v1/emails
with Authorization: Bearer fm_…
and Content-Type: application/json. The token selects the application and its space; you don’t send either ID.
curl https://fabrica.g.combine.mesa.ws/api/v1/emails \
-H "Authorization: Bearer $FABRICA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": [
"reader@example.com"
],
"from": "hello@your.app",
"subject": "Hello from Fabrica",
"text_body": "Your next good idea starts here."
}'
| Field | Contract |
|---|---|
from
|
Required sender; Name <email@example.com> is supported.
|
to
| Required address or array of addresses. At most 50 total across to, cc, and bcc. |
subject
| Required nonempty string, up to 2,000 bytes. |
text_body / html_body
| At least one nonempty body. Up to 5 MB each. |
cc / bcc / reply_to
| Optional recipients (address or array); reply_to is one address. |
tag / metadata
| Optional tag (1,000 bytes) and object (up to 20 string values). |
headers
|
Up to 20 objects with name and value.
|
attachments
|
Up to 20 objects with name, content_type, base64 content, and optional content_id.
|
The entire JSON request must be at most 10 MB, including base64 attachments. Use an array for addresses with commas in their display names. The message stream comes from the space configuration. Unknown fields are rejected.
3. Know what happened
{
"id": "019…",
"status": "accepted",
"message_id": "postmark-message-id"
}
200 means Postmark accepted the message; it does not guarantee inbox delivery. 401 means the application token is missing, invalid, paused, or revoked. 409 means the space has no email connection. 422 means invalid input or an explicit Postmark rejection. 502 means the provider result is unknown; 503 means the relay could not begin the attempt.
Errors use error.code
and error.message, with error.fields
for validation or error.provider_code
for Postmark. The console keeps the latest attempts with subjects and recipient counts. Bodies, addresses, attachments, and provider responses are not retained.
One request, one attempt.
Elixir, with your existing mailer
Copy fabrica_mail.ex
into your application’s lib/. It’s one Swoosh adapter module using :req. Configure it in config/runtime.exs:
config :my_app, MyApp.Mailer,
adapter: FabricaMail,
base_url: System.fetch_env!("FABRICA_URL"),
token: System.fetch_env!("FABRICA_TOKEN")
Set FABRICA_URL=https://fabrica.g.combine.mesa.ws
and your application token. Keep calling MyApp.Mailer.deliver(email). To, cc, bcc, reply-to, HTML, text, custom headers, attachments, tag, and metadata are supported.
Read the full module
defmodule FabricaMail do
@moduledoc """
Drop-in Swoosh adapter for Fabrica Mail. Copy this file into your application's
`lib/` directory. Requires the existing `:swoosh` and `:req` dependencies.
config :my_app, MyApp.Mailer,
adapter: FabricaMail,
base_url: System.fetch_env!("FABRICA_URL"),
token: System.fetch_env!("FABRICA_TOKEN")
Use the Fabrica origin as `base_url`, without `/api/v1`. Supports to/cc/bcc,
reply-to, HTML/text, custom headers and attachments (including inline images).
`put_provider_option(:tag, "welcome")` and `put_provider_option(:metadata, %{})` are
forwarded. Delivery is a single attempt: check Postmark before retrying an
ambiguous timeout. `{:ok, %{id: id}}` means provider acceptance, not delivery.
"""
use Swoosh.Adapter, required_config: [:base_url, :token]
@impl true
def deliver(%Swoosh.Email{} = email, config) do
payload =
%{
from: address(email.from),
to: Enum.map(email.to, &address/1),
cc: Enum.map(email.cc, &address/1),
bcc: Enum.map(email.bcc, &address/1),
reply_to: address(email.reply_to),
subject: email.subject,
html_body: email.html_body,
text_body: email.text_body,
tag: email.provider_options[:tag],
metadata: email.provider_options[:metadata],
headers: Enum.map(email.headers, fn {name, value} -> %{name: name, value: value} end),
attachments: Enum.map(email.attachments, &attachment/1)
}
|> Map.reject(fn {_, value} -> is_nil(value) end)
options = [
url: String.trim_trailing(Keyword.fetch!(config, :base_url), "/") <> "/api/v1/emails",
auth: {:bearer, Keyword.fetch!(config, :token)},
json: payload,
retry: false,
redirect: false,
receive_timeout: 30_000
]
# :req_options supports Req.Test in the consuming application's tests.
case Req.post(Keyword.merge(options, Keyword.get(config, :req_options, []))) do
{:ok, %{status: 200, body: %{"message_id" => id, "status" => "accepted"}}} ->
{:ok, %{id: id}}
{:ok, %{status: status, body: body}} ->
{:error, {status, body}}
{:error, error} ->
{:error, error}
end
end
defp address(nil), do: nil
defp address({"", email}), do: email
defp address({name, email}) do
# Quoted display names allow ordinary punctuation; Fabrica accepts a list
# of individual addresses so commas within names aren't split.
escaped = name |> String.replace("\\", "\\\\") |> String.replace("\"", "\\\"")
"\"#{escaped}\" <#{email}>"
end
defp attachment(attachment) do
%{
name: attachment.filename,
content_type: attachment.content_type,
content: Swoosh.Attachment.get_content(attachment, :base64)
}
|> then(fn item ->
if attachment.type == :inline and attachment.cid,
do: Map.put(item, :content_id, "cid:" <> String.trim_leading(attachment.cid, "cid:")),
else: item
end)
end
end