Repository: swoosh/swoosh
Pull request #1015 (2025-03-04 03:22:17)

Add PostUp adapter
Similar to https://github.com/swoosh/swoosh/pull/1012, this adapter is somewhat limited in scope but is convenient to implement as I am migrating off of Brevo to PostUp and would like to continue using Swoosh!

This adapter implements [sending a triggered mailing](https://apidocs.postup.com/docs/send-a-triggered-mailing) which can use either template variables or a custom HTML body, title, etc.

Like loops.so, this also has a `from` field that can be ignored, but in this case only if a dummy value is sent instead of a valid email address.


Patch:
diff --git a/README.md b/README.md
index c133a37..730235a 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,7 @@ included:
 | 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)             |                  |
 | Loops        | [Swoosh.Adapters.Loops](https://hexdocs.pm/swoosh/Swoosh.Adapters.Loops.html#content)               |                  |
+| PostUp       | [Swoosh.Adapters.PostUp](https://hexdocs.pm/swoosh/Swoosh.Adapters.PostUp.html#content)             |                  |
 
 Configure which adapter you want to use by updating your `config/config.exs`
 file:
diff --git a/lib/swoosh/adapters/postup.ex b/lib/swoosh/adapters/postup.ex
new file mode 100644
index 0000000..97b916c
--- /dev/null
+++ b/lib/swoosh/adapters/postup.ex
@@ -0,0 +1,216 @@
+defmodule Swoosh.Adapters.PostUp do
+  @moduledoc ~S"""
+  An adapter that sends email using the PostUp API, specifically triggered mailing. This
+  corresponds to transactional emails.
+
+  API reference: [PostUp API docs](https://apidocs.postup.com/docs/send-a-triggered-mailing)
+
+  **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.
+
+  ## Example
+
+      # config/config.exs
+      config :sample, Sample.Mailer,
+        adapter: Swoosh.Adapters.PostUp,
+        username: "BMO",
+        password: "hellofootball"
+
+      # lib/sample/mailer.ex
+      defmodule Sample.Mailer do
+        use Swoosh.Mailer, otp_app: :sample
+      end
+
+  ## Using with provider options
+
+  Specify custom tags as a string delimited by semicolons:
+
+      import Swoosh.Email
+
+      new()
+      |> from({"BMO", "bmo@example.com"})
+      |> to([
+        {["FirstName=Finn;LastName=Mertins;custom_tag=Something Else"], "finnthehuman@example.com"},
+        {["FirstName=Jake"], "jakethedog@example.com"},
+      ])
+      |> subject("BMO says hi!")
+      |> reply_to("Football@example.com")
+      |> html_body("<h1>Hello :)</h1>")
+      |> text_body("Hi!")
+      |> put_provider_option(:send_template_id, 42)
+
+  ## Usage with just template and no other options
+
+  Use an invalid email address to ignore the `from` option, which is required by Swoosh but causes
+  email templates to be overwritten as part of the "context" field in the request body.
+
+      import Swoosh.Email
+
+      new()
+      |> from("IGNORED")
+      |> to([
+        {"FirstName=Finn;LastName=Mertins", "finnthehuman@example.com"},
+        {"FirstName=Jake", "jakethedog@example.com"},
+      ])
+      |> put_provider_option(:send_template_id, 42)
+
+  ## Provider Options
+
+  Note that most of these options are nested under the optional "content" field in the JSON request
+  body alongside "fromEmail", "fromName", "htmlBody", etc.
+
+    * `send_template_id` (integer): unique number assigned to each send template.
+      **Required field.**
+
+    * `unsub_content_id` (integer): ID for replacement unsubscribe content in specified template.
+
+    * `reply_content_id` (integer): Same as above for "reply" content.
+
+    * `header_content_id` (integer): Same as above for header content.
+
+    * `footer_content_id` (integer): Same as above for footer content.
+
+    * `forward_to_friend_content_id` (integer): Same as above for "forward to friend" content.
+
+  """
+
+  use Swoosh.Adapter, required_config: [:username, :password]
+
+  require Logger
+  alias Swoosh.Email
+
+  @base_url "https://api.postup.com/api"
+  @api_endpoint "/templatemailing"
+  @provider_options [
+    :send_template_id,
+    :unsub_content_id,
+    :reply_content_id,
+    :header_content_id,
+    :footer_content_id,
+    :forward_to_friend_content_id
+  ]
+
+  defp base_url(config), do: config[:base_url] || @base_url
+
+  @impl Swoosh.Adapter
+  def deliver(%Email{} = email, config \\ []) do
+    credentials = Base.encode64("#{config[:username]}:#{config[:password]}")
+
+    headers = [
+      {"Accept", "application/json"},
+      {"Content-Type", "application/json"},
+      {"User-Agent", "swoosh/#{Swoosh.version()}"},
+      {"authorization", "Basic #{credentials}"}
+    ]
+
+    request_body = email |> prepare_payload() |> Swoosh.json_library().encode!
+    url = [base_url(config), @api_endpoint]
+
+    case Swoosh.ApiClient.post(url, headers, request_body, email) do
+      {:ok, code, _headers, response_body} when code >= 200 and code <= 399 ->
+        Swoosh.json_library().decode(response_body)
+
+      {:ok, code, _headers, body} when code >= 400 ->
+        case Swoosh.json_library().decode(body) do
+          {:ok, error} -> {:error, {code, error}}
+          {:error, _} -> {:error, {code, body}}
+        end
+
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+
+  defp prepare_payload(email) do
+    %{}
+    |> prepare_from(email)
+    |> prepare_reply_to(email)
+    |> prepare_to(email)
+    |> prepare_subject(email)
+    |> prepare_text_content(email)
+    |> prepare_html_content(email)
+    |> prepare_provider_option(email)
+  end
+
+  # fromEmail and fromName are not required fields when using a PostUp template.
+  # Swoosh requires a from field; specify some invalid email address to omit it from request body
+  defp prepare_from(payload, %Email{from: {name, email}}) when is_binary(email) do
+    cond do
+      (is_nil(name) or name == "") and not String.match?(email, ~r/^.+@.+\..+$/) ->
+        payload
+
+      true ->
+        payload
+        |> prepare_content("fromEmail", email)
+        |> prepare_content("fromName", name)
+    end
+  end
+
+  defp prepare_reply_to(payload, %Email{reply_to: nil}),
+    do: payload
+
+  defp prepare_reply_to(payload, %Email{reply_to: {name, reply_to}}) when name in [nil, ""],
+    do: prepare_content(payload, "replyToEmail", reply_to)
+
+  defp prepare_reply_to(payload, %Email{reply_to: {reply_to_name, reply_to_email}}) do
+    payload
+    |> prepare_content("replyToEmail", reply_to_email)
+    |> prepare_content("replyToName", reply_to_name)
+  end
+
+  defp prepare_to(payload, %Email{to: to}) do
+    recipients = Enum.map(to, &prepare_recipient/1)
+    Map.put(payload, "recipients", recipients)
+  end
+
+  defp prepare_subject(payload, %Email{subject: ""}), do: payload
+
+  defp prepare_subject(payload, %Email{subject: subject}),
+    do: prepare_content(payload, "subject", subject)
+
+  defp prepare_subject(payload, _), do: payload
+
+  defp prepare_text_content(payload, %Email{text_body: nil}), do: payload
+
+  defp prepare_text_content(payload, %Email{text_body: text_body}),
+    do: prepare_content(payload, "textBody", text_body)
+
+  defp prepare_html_content(payload, %Email{html_body: nil}), do: payload
+
+  defp prepare_html_content(payload, %Email{html_body: html_content}),
+    do: prepare_content(payload, "htmlBody", html_content)
+
+  defp prepare_provider_option(payload, %Email{provider_options: options}) do
+    _required = Map.fetch!(options, :send_template_id)
+
+    options
+    |> Map.take(@provider_options)
+    |> Enum.reduce(payload, fn
+      {:send_template_id, send_template_id}, acc ->
+        Map.put(acc, "SendTemplateId", send_template_id)
+
+      {key, value}, acc ->
+        content_key =
+          case key do
+            :unsub_content_id -> "unsubContentId"
+            :reply_content_id -> "replyContentId"
+            :header_content_id -> "headerContentId"
+            :footer_content_id -> "footerContentId"
+            :forward_to_friend_content_id -> "forwardToFriendContentId"
+          end
+
+        prepare_content(acc, content_key, value)
+    end)
+  end
+
+  defp prepare_recipient({nil, email}),
+    do: %{address: email, tags: []}
+
+  defp prepare_recipient({tags, email}) when is_binary(tags),
+    do: %{address: email, tags: String.split(tags, ";", trim: true)}
+
+  defp prepare_content(payload, key, value) when is_binary(key) do
+    put_in(payload, [Access.key("content", %{}), key], value)
+  end
+end


Test patch:
diff --git a/test/swoosh/adapters/postup_test.exs b/test/swoosh/adapters/postup_test.exs
new file mode 100644
index 0000000..9eb9d6b
--- /dev/null
+++ b/test/swoosh/adapters/postup_test.exs
@@ -0,0 +1,200 @@
+defmodule Swoosh.Adapters.PostUpTest do
+  use Swoosh.AdapterCase, async: true
+  import Swoosh.Email
+  alias Swoosh.Adapters.PostUp
+
+  setup do
+    bypass = Bypass.open()
+
+    config = [
+      base_url: "http://localhost:#{bypass.port}",
+      username: "testuser",
+      password: "testpassword321%"
+    ]
+
+    valid_email =
+      new()
+      |> from({"Test", "test@example.com"})
+      |> to({"CustomTag=foo;AnotherTag=bar", "recipient@example.com"})
+      |> subject("Test Email from PostUp")
+      |> put_provider_option(:send_template_id, 42)
+      |> text_body("Hey, is this thing on?")
+      |> html_body("<h1>Hello!</h1>")
+
+    {:ok, bypass: bypass, config: config, valid_email: valid_email}
+  end
+
+  test "successful delivery returns :ok", %{bypass: bypass, config: config, valid_email: email} do
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "SendTemplateId" => 42,
+        "content" => %{
+          "fromEmail" => "test@example.com",
+          "fromName" => "Test",
+          "htmlBody" => "<h1>Hello!</h1>",
+          "subject" => "Test Email from PostUp",
+          "textBody" => "Hey, is this thing on?"
+        },
+        "recipients" => [
+          %{"address" => "recipient@example.com", "tags" => ["CustomTag=foo", "AnotherTag=bar"]}
+        ]
+      }
+
+      assert body_params == conn.body_params
+      assert "/templatemailing" == conn.request_path
+      assert "POST" == conn.method
+      assert body_params == conn.body_params
+
+      Plug.Conn.resp(conn, 200, "{\"status\":\"DONE\"}")
+    end)
+
+    assert PostUp.deliver(email, config) == {:ok, %{"status" => "DONE"}}
+  end
+
+  test "deliver/1 with multiple recipients returns :ok", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from({"Test", "test@example.com"})
+      |> to("no.custom.tags@example.com")
+      |> to({"CustomTag=foo;AnotherTag=bar", "recipient@example.com"})
+      |> subject("Test Email from PostUp")
+      |> put_provider_option(:send_template_id, 42)
+      |> text_body("Hey, is this thing on?")
+      |> html_body("<h1>Hello!</h1>")
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "SendTemplateId" => 42,
+        "content" => %{
+          "fromEmail" => "test@example.com",
+          "fromName" => "Test",
+          "htmlBody" => "<h1>Hello!</h1>",
+          "subject" => "Test Email from PostUp",
+          "textBody" => "Hey, is this thing on?"
+        },
+        "recipients" => [
+          %{"address" => "recipient@example.com", "tags" => ["CustomTag=foo", "AnotherTag=bar"]},
+          %{"address" => "no.custom.tags@example.com", "tags" => []}
+        ]
+      }
+
+      assert body_params == conn.body_params
+      assert "/templatemailing" == conn.request_path
+      assert "POST" == conn.method
+      assert body_params == conn.body_params
+
+      Plug.Conn.resp(conn, 200, "{\"status\":\"DONE\"}")
+    end)
+
+    assert PostUp.deliver(email, config) == {:ok, %{"status" => "DONE"}}
+  end
+
+  test "deliver/1 with additional provider options returns :ok", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from("IGNORE")
+      |> to("recipient@example.com")
+      |> put_provider_option(:send_template_id, 42)
+      |> put_provider_option(:header_content_id, 123)
+      |> put_provider_option(:footer_content_id, 314)
+      |> put_provider_option(:unsub_content_id, 248)
+      |> put_provider_option(:forward_to_friend_content_id, 963)
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "SendTemplateId" => 42,
+        "content" => %{
+          "footerContentId" => 314,
+          "forwardToFriendContentId" => 963,
+          "headerContentId" => 123,
+          "unsubContentId" => 248
+        },
+        "recipients" => [%{"address" => "recipient@example.com", "tags" => []}]
+      }
+
+      assert body_params == conn.body_params
+      assert "/templatemailing" == conn.request_path
+      assert "POST" == conn.method
+      assert body_params == conn.body_params
+
+      Plug.Conn.resp(conn, 200, "{\"status\":\"DONE\"}")
+    end)
+
+    assert PostUp.deliver(email, config) == {:ok, %{"status" => "DONE"}}
+  end
+
+  test ~s(deliver/1 with no from or "context" field returns :ok), %{
+    bypass: bypass,
+    config: config
+  } do
+    email =
+      new()
+      |> from("SKIP")
+      |> to("anotherone@example.com")
+      |> to("recipient@example.com")
+      |> put_provider_option(:send_template_id, 42)
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "SendTemplateId" => 42,
+        "recipients" => [
+          %{"address" => "recipient@example.com", "tags" => []},
+          %{"address" => "anotherone@example.com", "tags" => []}
+        ]
+      }
+
+      assert body_params == conn.body_params
+      assert "/templatemailing" == conn.request_path
+      assert "POST" == conn.method
+      assert body_params == conn.body_params
+
+      Plug.Conn.resp(conn, 200, "{\"status\":\"DONE\"}")
+    end)
+
+    assert PostUp.deliver(email, config) == {:ok, %{"status" => "DONE"}}
+  end
+
+  test "deliver/1 with 5xx response", %{bypass: bypass, config: config, valid_email: email} do
+    Bypass.expect(bypass, fn conn ->
+      assert "/templatemailing" == conn.request_path
+      assert "POST" == conn.method
+      Plug.Conn.resp(conn, 500, "Server error")
+    end)
+
+    assert PostUp.deliver(email, config) == {:error, {500, "Server error"}}
+  end
+
+  test "validate_config/1 with valid config", %{config: config} do
+    assert PostUp.validate_config(config) == :ok
+  end
+
+  test "validate_config/1 with missing username and password" do
+    assert_raise ArgumentError, "expected [:password, :username] to be set, got: []\n", fn ->
+      PostUp.validate_config([])
+    end
+  end
+
+  test "validate_config/1 with missing password" do
+    assert_raise ArgumentError,
+                 ~s(expected [:password] to be set, got: [username: "test"]\n),
+                 fn ->
+                   PostUp.validate_config(username: "test")
+                 end
+  end
+
+  test "validate_config/1 with missing username" do
+    assert_raise ArgumentError,
+                 ~s(expected [:username] to be set, got: [password: "testpassword"]\n),
+                 fn ->
+                   PostUp.validate_config(password: "testpassword")
+                 end
+  end
+end