Repository: swoosh/swoosh
Pull request #949 (2024-08-25 16:32:34)

Implement Postal adapter
Postal is open-source, self hosted transactional mail service. See https://docs.postalserver.io/

This is my first contribution so I'm open to feedback.

princemaple: Hi, thanks for the PR. This is missing unit tests that should show the basic scenarios of using this adapter. (Success response shape, error response shape and handling)
onvlt: @princemaple Thanks for feedback, I've added the tests.

Patch:
diff --git a/README.md b/README.md
index c67a52f..5ba6e0a 100644
--- a/README.md
+++ b/README.md
@@ -151,6 +151,7 @@ included:
 | ProtonBridge | [Swoosh.Adapters.ProtonBridge](https://hexdocs.pm/swoosh/Swoosh.Adapters.ProtonBridge.html#content) |                  |
 | Mailtrap     | [Swoosh.Adapters.Mailtrap](https://hexdocs.pm/swoosh/Swoosh.Adapters.Mailtrap.html#content)         |                  |
 | ZeptoMail    | [Swoosh.Adapters.ZeptoMail](https://hexdocs.pm/swoosh/Swoosh.Adapters.ZeptoMail.html#content)       |                  |
+| Postal       | [Swoosh.Adapters.Postal](https://hexdocs.pm/swoosh/Swoosh.Adapters.Postal.html#content)             |                  |
 
 Configure which adapter you want to use by updating your `config/config.exs`
 file:
diff --git a/lib/swoosh/adapters/postal.ex b/lib/swoosh/adapters/postal.ex
new file mode 100644
index 0000000..cec037f
--- /dev/null
+++ b/lib/swoosh/adapters/postal.ex
@@ -0,0 +1,168 @@
+defmodule Swoosh.Adapters.Postal do
+  @moduledoc ~S"""
+  An adapter that sends email using the Postal API.
+  [Postal](https://docs.postalserver.io/) is open-source, self-hosted mail delivery platform.
+
+  For reference: [Postal API docs](https://apiv1.postalserver.io/)
+
+  **This adapter requires an API Client.** Swoosh comes with Hackney, Finch and Req out of the box.
+  See the [installation section](https://hexdocs.pm/swoosh/Swoosh.html#module-installation)
+  for details.
+
+  ## Configuration options
+
+  * `:api_key` - Postal API key.
+  * `:base_url` - Base URL where Postal server is running.
+
+  ## Example
+
+      # config/config.exs
+      config :sample, Sample.Mailer,
+        adapter: Swoosh.Adapters.Postal,
+        api_key: "my-api-key",
+        base_url: "https://my-postal-server.com/"
+
+      # lib/sample/mailer.ex
+      defmodule Sample.Mailer do
+        use Swoosh.Mailer, otp_app: :sample
+      end
+
+  ## Using with provider options
+
+      import Swoosh.Email
+
+      new()
+      |> from({"T Stark", "tony.stark@example.com"})
+      |> to({"Steve Rogers", "steve.rogers@example.com"})
+      |> to("wasp.avengers@example.com")
+      |> reply_to("office.avengers@example.com")
+      |> cc({"Bruce Banner", "hulk.smash@example.com"})
+      |> cc("thor.odinson@example.com")
+      |> bcc({"Clinton Francis Barton", "hawk.eye@example.com"})
+      |> bcc("beast.avengers@example.com")
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+      |> text_body("Hello")
+      |> put_provider_option(:tag, "avengers")
+      |> put_provider_option(:bounce, true)
+  """
+
+  use Swoosh.Adapter,
+    required_config: [:api_key, :base_url]
+
+  alias Swoosh.Attachment
+  alias Swoosh.Email
+  import Swoosh.Email.Render
+
+  @api_endpoint "/api/v1/send/message"
+
+  @impl true
+  def deliver(%Email{} = email, config \\ []) do
+    body = prepare_body(email) |> Swoosh.json_library().encode!
+    headers = prepare_headers(config)
+    url = [base_url(config), @api_endpoint]
+
+    case Swoosh.ApiClient.post(url, headers, body, email) do
+      {:ok, 200, _headers, body} ->
+        case Swoosh.json_library().decode!(body) do
+          %{"status" => "success", "data" => data} ->
+            {:ok, %{id: data["message_id"]}}
+
+          %{"status" => _, "data" => data} ->
+            {:error, {data["code"], data["message"]}}
+        end
+
+      {:ok, code, _headers, body} when code > 399 ->
+        case Swoosh.json_library().decode(body) do
+          {:ok, error} -> {:error, {code, error}}
+          {:error, _} -> {:error, {code, body}}
+        end
+
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+
+  defp base_url(config), do: config[:base_url]
+
+  defp prepare_headers(config) do
+    [
+      {"User-Agent", "swoosh/#{Swoosh.version()}"},
+      {"Content-Type", "application/json"},
+      {"Accept", "application/json"},
+      {"X-Server-API-Key", config[:api_key]}
+    ]
+  end
+
+  defp prepare_body(%Email{} = email) do
+    %{}
+    |> prepare_from(email)
+    |> prepare_to(email)
+    |> prepare_subject(email)
+    |> prepare_html(email)
+    |> prepare_text(email)
+    |> prepare_cc(email)
+    |> prepare_bcc(email)
+    |> prepare_reply_to(email)
+    |> prepare_attachments(email)
+    |> prepare_tag(email)
+    |> prepare_bounce(email)
+    |> prepare_custom_headers(email)
+  end
+
+  defp prepare_from(body, %Email{from: from}),
+    do: Map.put(body, :from, render_recipient(from))
+
+  defp prepare_to(body, %Email{to: to}),
+    do: Map.put(body, :to, Enum.map(to, &render_recipient(&1)))
+
+  defp prepare_cc(body, %Email{cc: []}), do: body
+
+  defp prepare_cc(body, %Email{cc: cc}),
+    do: Map.put(body, :cc, Enum.map(cc, &render_recipient(&1)))
+
+  defp prepare_bcc(body, %Email{bcc: []}), do: body
+
+  defp prepare_bcc(body, %Email{bcc: bcc}),
+    do: Map.put(body, :bcc, Enum.map(bcc, &render_recipient(&1)))
+
+  defp prepare_reply_to(body, %Email{reply_to: nil}), do: body
+
+  defp prepare_reply_to(body, %Email{reply_to: reply_to}),
+    do: Map.put(body, :reply_to, render_recipient(reply_to))
+
+  defp prepare_subject(body, %Email{subject: subject}),
+    do: Map.put(body, :subject, subject)
+
+  defp prepare_text(body, %Email{text_body: nil}), do: body
+  defp prepare_text(body, %Email{text_body: text_body}), do: Map.put(body, :plain_body, text_body)
+
+  defp prepare_html(body, %Email{html_body: nil}), do: body
+  defp prepare_html(body, %Email{html_body: html_body}), do: Map.put(body, :html_body, html_body)
+
+  defp prepare_tag(body, %Email{provider_options: %{tag: tag}}), do: Map.put(body, :tag, tag)
+  defp prepare_tag(body, _), do: body
+
+  defp prepare_bounce(body, %Email{provider_options: %{bounce: bounce}}) when is_boolean(bounce),
+    do: Map.put(body, :bounce, bounce)
+
+  defp prepare_bounce(body, _), do: body
+
+  defp prepare_custom_headers(body, %{headers: headers}) when map_size(headers) != 0,
+    do: Map.put(body, :headers, headers)
+
+  defp prepare_custom_headers(body, _), do: body
+
+  defp prepare_attachments(body, %Email{attachments: []}), do: body
+
+  defp prepare_attachments(body, %Email{attachments: attachments}),
+    do: Map.put(body, :attachments, Enum.map(attachments, &prepare_attachment(&1)))
+
+  defp prepare_attachment(%Attachment{} = attachment) do
+    %{
+      name: attachment.filename,
+      content_type: attachment.content_type,
+      data: Swoosh.Attachment.get_content(attachment, :base64)
+    }
+  end
+end


Test patch:
diff --git a/test/integration/adapters/postal_test.exs b/test/integration/adapters/postal_test.exs
new file mode 100644
index 0000000..bec8c17
--- /dev/null
+++ b/test/integration/adapters/postal_test.exs
@@ -0,0 +1,53 @@
+defmodule Swoosh.Integration.Adapters.PostalTest do
+  use ExUnit.Case, async: true
+
+  import Swoosh.Email
+
+  @moduletag integration: true
+
+  setup_all do
+    config = [
+      base_url: System.get_env("POSTAL_BASE_URL"),
+      api_key: System.get_env("POSTAL_API_KEY"),
+      domain: System.get_env("POSTAL_DOMAIN")
+    ]
+
+    {:ok, config: config}
+  end
+
+  test "simple deliver", %{config: config} do
+    email =
+      new()
+      |> from({"Swoosh Postal", "swoosh+postal@#{config[:domain]}"})
+      |> reply_to("swoosh+replyto@#{config[:domain]}")
+      |> to("swoosh+to@#{config[:domain]}")
+      |> cc("swoosh+cc@#{config[:domain]}")
+      |> bcc("swoosh+bcc@#{config[:domain]}")
+      |> subject("Swoosh - Postal integration test")
+      |> text_body("This email was sent by the Swoosh library automation testing")
+      |> html_body("<p>This email was sent by the Swoosh library automation testing</p>")
+      |> put_provider_option(:tag, "swoosh-integration-test")
+      |> attachment(%Swoosh.Attachment{
+        content_type: "text/plain",
+        data: "this is an attachment",
+        filename: "example.txt",
+        type: :attachment,
+        headers: []
+      })
+
+    assert {:ok, _response} = Swoosh.Adapters.Postal.deliver(email, config)
+  end
+
+  test ":error with wrong api key", %{config: config} do
+    config = Keyword.put(config, :api_key, "bad_key")
+
+    email =
+      new()
+      |> from({"Swoosh Postal", "swoosh+postal@#{config[:domain]}"})
+      |> to("swoosh+to@#{config[:domain]}")
+      |> subject("Swoosh - Postal integration test")
+      |> html_body("<p>This email was sent by the Swoosh library automation testing</p>")
+
+    assert {:error, {"InvalidServerAPIKey", _}} = Swoosh.Adapters.Postal.deliver(email, config)
+  end
+end
diff --git a/test/swoosh/adapters/postal_test.exs b/test/swoosh/adapters/postal_test.exs
new file mode 100644
index 0000000..c9a9c99
--- /dev/null
+++ b/test/swoosh/adapters/postal_test.exs
@@ -0,0 +1,184 @@
+defmodule Swoosh.Adapters.PostalTest do
+  use Swoosh.AdapterCase, async: true
+
+  import Swoosh.Email
+  alias Swoosh.Adapters.Postal
+
+  @success_response """
+  {
+    "status": "success",
+    "time": 0.123,
+    "flags": {},
+    "data": {
+      "message_id": "d7eabab3-5936-42ff-a419-711cc76f23c8@test.postalserver.io"
+    }
+  }
+  """
+
+  setup do
+    bypass = Bypass.open()
+    config = [api_key: "123", base_url: "http://localhost:#{bypass.port}"]
+
+    valid_email =
+      new()
+      |> from("tony.stark@example.com")
+      |> to({"Steve Rogers", "steve.rogers@example.com"})
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+      |> text_body("Hello")
+
+    {:ok, bypass: bypass, config: config, valid_email: valid_email}
+  end
+
+  test "succesful delivery returns :ok", %{
+    bypass: bypass,
+    config: config,
+    valid_email: email
+  } do
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => "tony.stark@example.com",
+        "to" => [~s("Steve Rogers" <steve.rogers@example.com>)],
+        "subject" => "Hello, Avengers!",
+        "html_body" => "<h1>Hello</h1>",
+        "plain_body" => "Hello"
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/v1/send/message" == conn.request_path
+      assert "POST" == conn.method
+      assert {"content-type", "application/json"} in conn.req_headers
+
+      conn
+      |> Plug.Conn.put_resp_header("content-type", "application/json")
+      |> Plug.Conn.resp(200, @success_response)
+    end)
+
+    assert Postal.deliver(email, config) ==
+             {:ok, %{id: "d7eabab3-5936-42ff-a419-711cc76f23c8@test.postalserver.io"}}
+  end
+
+  test "deliver/1 with all fields returns :ok", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from({"T Stark", "tony.stark@example.com"})
+      |> to({"Steve Rogers", "steve.rogers@example.com"})
+      |> to("wasp.avengers@example.com")
+      |> reply_to("office.avengers@example.com")
+      |> cc({"Bruce Banner", "hulk.smash@example.com"})
+      |> cc("thor.odinson@example.com")
+      |> bcc({"Clinton Francis Barton", "hawk.eye@example.com"})
+      |> bcc("beast.avengers@example.com")
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+      |> text_body("Hello")
+      |> header("x-custom-header", "hello")
+      |> attachment("test/support/attachment.txt")
+      |> attachment(
+        Swoosh.Attachment.new(
+          {:data, "text stuff"},
+          filename: "foo.txt",
+          content_type: "text/plain"
+        )
+      )
+      |> put_provider_option(:tag, "Avengers")
+      |> put_provider_option(:bounce, false)
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "subject" => "Hello, Avengers!",
+        "to" => [
+          ~s(wasp.avengers@example.com),
+          ~s("Steve Rogers" <steve.rogers@example.com>)
+        ],
+        "bcc" => [
+          ~s(beast.avengers@example.com),
+          ~s("Clinton Francis Barton" <hawk.eye@example.com>)
+        ],
+        "cc" => [
+          ~s(thor.odinson@example.com),
+          ~s("Bruce Banner" <hulk.smash@example.com>)
+        ],
+        "reply_to" => "office.avengers@example.com",
+        "from" => ~s("T Stark" <tony.stark@example.com>),
+        "plain_body" => "Hello",
+        "html_body" => "<h1>Hello</h1>",
+        "bounce" => false,
+        "headers" => %{"x-custom-header" => "hello"},
+        "tag" => "Avengers",
+        "attachments" => [
+          %{
+            "name" => "foo.txt",
+            "content_type" => "text/plain",
+            "data" => Base.encode64("text stuff")
+          },
+          %{
+            "name" => "attachment.txt",
+            "content_type" => "text/plain",
+            "data" => Base.encode64(File.read!("test/support/attachment.txt"))
+          }
+        ]
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/v1/send/message" == conn.request_path
+      assert "POST" == conn.method
+      assert {"content-type", "application/json"} in conn.req_headers
+
+      conn
+      |> Plug.Conn.put_resp_header("content-type", "application/json")
+      |> Plug.Conn.resp(200, @success_response)
+    end)
+
+    assert Postal.deliver(email, config) ==
+             {:ok, %{id: "d7eabab3-5936-42ff-a419-711cc76f23c8@test.postalserver.io"}}
+  end
+
+  test "deliver/1 fails when API returns ok response with non-success status", %{
+    bypass: bypass,
+    config: config,
+    valid_email: email
+  } do
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      response = """
+      {
+      "status": "error",
+      "time": null,
+      "flags": {},
+      "data": {
+        "code": "AccessDenied",
+        "message": "Must be authenticated as a server.",
+        "action": "message",
+        "controller": "send"
+      }
+      }
+      """
+
+      conn
+      |> Plug.Conn.put_resp_header("content-type", "application/json")
+      |> Plug.Conn.resp(200, response)
+    end)
+
+    assert Postal.deliver(email, config) ==
+             {:error, {"AccessDenied", "Must be authenticated as a server."}}
+  end
+
+  test "deliver/1 fails when API returns non-ok response", %{
+    bypass: bypass,
+    config: config,
+    valid_email: email
+  } do
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+      Plug.Conn.resp(conn, 500, "")
+    end)
+
+    assert Postal.deliver(email, config) == {:error, {500, ""}}
+  end
+end