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.

Fabrica does not queue, automatically retry, or deduplicate messages. After an unknown result, a timeout, or a stuck “sending” attempt, check Postmark activity before resending. Postmark webhooks and delivery tracking are not part of this API.

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.

Download the adapter
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