Repository: swoosh/swoosh
Pull request #825 (2023-10-18 09:50:34)

Add Scaleway adapter
This PR adds an adapter for Scaleway (transactional emails only).

Ref.: https://www.scaleway.com/en/developers/api/transactional-email/

princemaple: Thank you @andreh11 , this looks good. I'll have a more thorough review in a few days and merge it then :) Didn't know scaleway has an Email service now. Its docs says `v1alpha1`, do you know if it's stable already or when it will be?
andreh11: Well, I've been using it in production for only 2 weeks. No problem so far, but this is not a proof of stability. I've just asked Scaleway's support, we'll see. Maybe we can wait for their answer before merging.

Also, the API documentation says nothing about 4xx and 5xx errors, so the related tests are just copy/pasted from `brevo_test.exs`. I'd be happy to improve this part, but how?
andreh11: I've asked Scaleway about `v1alpha1`. They've confirmed that the API is stable. They won't change the endpoint's URI.

Patch:
diff --git a/lib/swoosh/adapters/scaleway.ex b/lib/swoosh/adapters/scaleway.ex
new file mode 100644
index 0000000..5d5356f
--- /dev/null
+++ b/lib/swoosh/adapters/scaleway.ex
@@ -0,0 +1,185 @@
+defmodule Swoosh.Adapters.Scaleway do
+  @moduledoc ~S"""
+  An adapter that sends email using the Scaleway API (Transactional emails only).
+
+  For reference: [Scaleway API docs](https://www.scaleway.com/en/developers/api/transactional-email/#path-emails-send-an-email)
+
+  **This adapter requires an API Client.** Swoosh comes with Hackney and Finch 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.Scaleway,
+        project_id: "my-project-id"
+        secret_key: "my-api-key"
+
+      # lib/sample/mailer.ex
+      defmodule Sample.Mailer do
+        use Swoosh.Mailer, otp_app: :sample
+      end
+
+  ## Using with provider options
+
+      import Swoosh.Email
+
+      new()
+      |> from("nora@example.com")
+      |> to("shushu@example.com")
+      |> subject("Hello, Wally!")
+      |> text_body("Hello")
+      |> put_provider_option(:send_before, ~U[2022-11-15 11:00:00Z])
+
+  ## Provider Options
+
+    * `send_before` (RFC 3339 format) - `send_before`, maximum date to deliver the email.
+
+  """
+
+  use Swoosh.Adapter, required_config: [:project_id, :secret_key]
+
+  alias Swoosh.Email
+
+  @base_url "https://api.scaleway.com/transactional-email/v1alpha1/regions/fr-par"
+  @api_endpoint "/emails"
+
+  defp base_url(config), do: config[:base_url] || @base_url
+
+  @impl true
+  def deliver(%Email{} = email, config \\ []) do
+    headers = prepare_headers(config)
+    body = email |> prepare_payload(config[:project_id]) |> Swoosh.json_library().encode!
+    url = [base_url(config), @api_endpoint]
+
+    case Swoosh.ApiClient.post(url, headers, body, email) do
+      {:ok, code, _headers, body} when code >= 200 and code <= 399 ->
+        {:ok, %{id: extract_message_id(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_headers(config) do
+    [
+      {"Accept", "application/json"},
+      {"Content-Type", "application/json"},
+      {"User-Agent", "swoosh/#{Swoosh.version()}"},
+      {"X-Auth-Token", config[:secret_key]}
+    ]
+  end
+
+  defp extract_message_id(body) do
+    body
+    |> Swoosh.json_library().decode!()
+    |> Map.get("emails")
+    |> hd()
+    |> Map.get("message_id")
+  end
+
+  defp prepare_payload(email, project_id) do
+    %{}
+    |> prepare_project_id(project_id)
+    |> prepare_from(email)
+    |> prepare_reply_to(email)
+    |> prepare_to(email)
+    |> prepare_cc(email)
+    |> prepare_bcc(email)
+    |> prepare_subject(email)
+    |> prepare_text_content(email)
+    |> prepare_html_content(email)
+    |> prepare_headers(email)
+    |> prepare_params(email)
+    |> prepare_attachments(email)
+    |> prepare_send_before(email)
+  end
+
+  defp prepare_project_id(payload, project_id), do: Map.put(payload, "project_id", project_id)
+
+  defp prepare_from(payload, %{from: from}), do: Map.put(payload, "from", prepare_recipient(from))
+
+  defp prepare_reply_to(payload, _reply_to), do: payload
+
+  defp prepare_to(payload, %{to: to}) do
+    Map.put(payload, "to", Enum.map(to, &prepare_recipient/1))
+  end
+
+  defp prepare_cc(payload, %{cc: []}), do: payload
+
+  defp prepare_cc(payload, %{cc: cc}) do
+    Map.put(payload, "cc", Enum.map(cc, &prepare_recipient/1))
+  end
+
+  defp prepare_bcc(payload, %{bcc: []}), do: payload
+
+  defp prepare_bcc(payload, %{bcc: bcc}) do
+    Map.put(payload, "bcc", Enum.map(bcc, &prepare_recipient/1))
+  end
+
+  defp prepare_subject(payload, %{subject: ""}), do: payload
+
+  defp prepare_subject(payload, %{subject: subject}),
+    do: Map.put(payload, "subject", subject)
+
+  defp prepare_subject(payload, _), do: payload
+
+  defp prepare_text_content(payload, %{text_body: nil}), do: payload
+
+  defp prepare_text_content(payload, %{text_body: text_content}) do
+    Map.put(payload, "text", text_content)
+  end
+
+  defp prepare_html_content(payload, %{html_body: nil}), do: payload
+
+  defp prepare_html_content(payload, %{html_body: html_content}) do
+    Map.put(payload, "html", html_content)
+  end
+
+  defp prepare_headers(payload, %{headers: map}) when map_size(map) == 0, do: payload
+
+  defp prepare_headers(payload, %{headers: headers}) do
+    Map.put(payload, "headers", headers)
+  end
+
+  defp prepare_params(payload, %{provider_options: %{params: params}}) when is_map(params) do
+    Map.put(payload, "params", params)
+  end
+
+  defp prepare_params(payload, _), do: payload
+
+  defp prepare_attachments(payload, %{attachments: []}), do: payload
+
+  defp prepare_attachments(payload, %{attachments: attachments}) do
+    Map.put(
+      payload,
+      "attachments",
+      Enum.map(
+        attachments,
+        &%{
+          name: &1.filename,
+          content: Swoosh.Attachment.get_content(&1, :base64)
+        }
+      )
+    )
+  end
+
+  defp prepare_recipient({name, email}) when name in [nil, ""],
+    do: %{email: email}
+
+  defp prepare_recipient({name, email}),
+    do: %{name: name, email: email}
+
+  defp prepare_send_before(payload, %{provider_options: %{send_before: send_before}}) do
+    Map.put(payload, "send_before", send_before)
+  end
+
+  defp prepare_send_before(payload, _), do: payload
+end


Test patch:
diff --git a/test/swoosh/adapters/scaleway_test.exs b/test/swoosh/adapters/scaleway_test.exs
new file mode 100644
index 0000000..a030e8a
--- /dev/null
+++ b/test/swoosh/adapters/scaleway_test.exs
@@ -0,0 +1,240 @@
+defmodule Swoosh.Adapters.ScalewayTest do
+  use Swoosh.AdapterCase, async: true
+
+  import Swoosh.Email
+  alias Swoosh.Adapters.Scaleway
+
+  @example_message_id "<42.11@relay.example.com>"
+
+  setup do
+    bypass = Bypass.open()
+
+    config = [
+      secret_key: "123",
+      project_id: "ABC",
+      base_url: "http://localhost:#{bypass.port}"
+    ]
+
+    valid_email =
+      new()
+      |> from("tony.stark@example.com")
+      |> to("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
+
+  defp make_response(conn) do
+    conn
+    |> Plug.Conn.resp(200, """
+        {
+          "emails": [
+            {
+              "message_id": "#{@example_message_id}",
+              "project_id": "ABC"
+            }
+          ]
+        }
+    """)
+  end
+
+  test "successful delivery returns :ok", %{bypass: bypass, config: config, valid_email: email} do
+    Bypass.expect_once(bypass, "POST", "/emails", fn conn ->
+      conn = parse(conn)
+
+      assert conn.body_params == %{
+               "from" => %{"email" => "tony.stark@example.com"},
+               "to" => [%{"email" => "steve.rogers@example.com"}],
+               "project_id" => "ABC",
+               "html" => "<h1>Hello</h1>",
+               "text" => "Hello",
+               "subject" => "Hello, Avengers!"
+             }
+
+      make_response(conn)
+    end)
+
+    assert Scaleway.deliver(email, config) == {:ok, %{id: "#{@example_message_id}"}}
+  end
+
+  test "text-only delivery returns :ok", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from("tony.stark@example.com")
+      |> to("steve.rogers@example.com")
+      |> subject("Hello, Avengers!")
+      |> text_body("Hello")
+
+    Bypass.expect_once(bypass, "POST", "/emails", fn conn ->
+      conn = parse(conn)
+
+      assert conn.body_params == %{
+               "from" => %{"email" => "tony.stark@example.com"},
+               "to" => [%{"email" => "steve.rogers@example.com"}],
+               "project_id" => "ABC",
+               "text" => "Hello",
+               "subject" => "Hello, Avengers!"
+             }
+
+      make_response(conn)
+    end)
+
+    assert Scaleway.deliver(email, config) == {:ok, %{id: "#{@example_message_id}"}}
+  end
+
+  test "html-only delivery returns :ok", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from("tony.stark@example.com")
+      |> to("steve.rogers@example.com")
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+
+    Bypass.expect_once(bypass, "POST", "/emails", fn conn ->
+      conn = parse(conn)
+
+      assert conn.body_params == %{
+               "from" => %{"email" => "tony.stark@example.com"},
+               "to" => [%{"email" => "steve.rogers@example.com"}],
+               "project_id" => "ABC",
+               "html" => "<h1>Hello</h1>",
+               "subject" => "Hello, Avengers!"
+             }
+
+      make_response(conn)
+    end)
+
+    assert Scaleway.deliver(email, config) == {:ok, %{id: "#{@example_message_id}"}}
+  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"})
+      |> reply_to("hulk.smash@example.com")
+      |> cc("hulk.smash@example.com")
+      |> cc({"Janet Pym", "wasp.avengers@example.com"})
+      |> bcc("thor.odinson@example.com")
+      |> bcc({"Henry McCoy", "beast.avengers@example.com"})
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+      |> text_body("Hello")
+
+    Bypass.expect_once(bypass, "POST", "/emails", fn conn ->
+      conn = parse(conn)
+
+      assert conn.body_params == %{
+               "from" => %{"name" => "T Stark", "email" => "tony.stark@example.com"},
+               "to" => [%{"name" => "Steve Rogers", "email" => "steve.rogers@example.com"}],
+               "project_id" => "ABC",
+               "cc" => [
+                 %{"name" => "Janet Pym", "email" => "wasp.avengers@example.com"},
+                 %{"email" => "hulk.smash@example.com"}
+               ],
+               "bcc" => [
+                 %{"name" => "Henry McCoy", "email" => "beast.avengers@example.com"},
+                 %{"email" => "thor.odinson@example.com"}
+               ],
+               "text" => "Hello",
+               "html" => "<h1>Hello</h1>",
+               "subject" => "Hello, Avengers!"
+             }
+
+      make_response(conn)
+    end)
+
+    assert Scaleway.deliver(email, config) == {:ok, %{id: "#{@example_message_id}"}}
+  end
+
+  test "deliver/1 with send_before returns :ok", %{bypass: bypass, config: config} do
+    now_plus_one_hour = DateTime.now!("Etc/UTC") |> DateTime.add(3600, :second)
+
+    email =
+      new()
+      |> from({"T Stark", "tony.stark@example.com"})
+      |> to({"Steve Rogers", "steve.rogers@example.com"})
+      |> subject("Hello, Avengers!")
+      |> put_provider_option(:send_before, now_plus_one_hour)
+
+    Bypass.expect_once(bypass, "POST", "/emails", fn conn ->
+      conn = parse(conn)
+
+      assert %{
+               "from" => %{"name" => "T Stark", "email" => "tony.stark@example.com"},
+               "to" => [%{"name" => "Steve Rogers", "email" => "steve.rogers@example.com"}],
+               "project_id" => "ABC",
+               "subject" => "Hello, Avengers!",
+               "send_before" => now_plus_one_hour_sent
+             } = conn.body_params
+
+      {:ok, parsed_sent_schedule_date, 0} = DateTime.from_iso8601(now_plus_one_hour_sent)
+
+      assert parsed_sent_schedule_date == now_plus_one_hour
+
+      make_response(conn)
+    end)
+
+    assert Scaleway.deliver(email, config) == {:ok, %{id: "#{@example_message_id}"}}
+  end
+
+  test "deliver/1 with 429 response", %{bypass: bypass, config: config, valid_email: email} do
+    error = ~s/{"code": "too_many_requests", "message": "The expected rate limit is exceeded."}/
+
+    Bypass.expect_once(bypass, &Plug.Conn.resp(&1, 429, error))
+
+    response =
+      {:error,
+       {429,
+        %{
+          "code" => "too_many_requests",
+          "message" => "The expected rate limit is exceeded."
+        }}}
+
+    assert Scaleway.deliver(email, config) == response
+  end
+
+  test "deliver/1 with 4xx response", %{bypass: bypass, config: config, valid_email: email} do
+    error = ~s/{"code": "invalid_parameter", "message": "error message explained."}/
+
+    Bypass.expect_once(bypass, &Plug.Conn.resp(&1, 400, error))
+
+    response =
+      {:error,
+       {400,
+        %{
+          "code" => "invalid_parameter",
+          "message" => "error message explained."
+        }}}
+
+    assert Scaleway.deliver(email, config) == response
+  end
+
+  test "deliver/1 with 5xx response", %{bypass: bypass, config: config, valid_email: email} do
+    Bypass.expect_once(bypass, "POST", "/emails", fn conn ->
+      assert "/emails" == conn.request_path
+      assert "POST" == conn.method
+      Plug.Conn.resp(conn, 500, "")
+    end)
+
+    assert Scaleway.deliver(email, config) == {:error, {500, ""}}
+  end
+
+  test "validate_config/1 with valid config", %{config: config} do
+    assert Scaleway.validate_config(config) == :ok
+  end
+
+  test "validate_config/1 with invalid config" do
+    assert_raise(
+      ArgumentError,
+      """
+      expected [:secret_key, :project_id] to be set, got: []
+      """,
+      fn ->
+        Scaleway.validate_config([])
+      end
+    )
+  end
+end