Repository: swoosh/swoosh
Pull request #827 (2023-10-24 18:22:49)

Implement Mailtrap adapter
This PR adds an adapter for Mailtrap Sending and Sandbox APIs.

API docs: https://api-docs.mailtrap.io/docs/mailtrap-api-docs/5tjdeg9545058-mailtrap-api

sweep-ai[bot]: ## Apply [Sweep Rules](https://docs.sweep.dev/usage/config#rules) to your PR?
- [ ] **Apply:**  Leftover TODOs in the code should be handled.
- [ ] **Apply:**  All new business logic should have corresponding unit tests in the tests/ directory.
- [ ] **Apply:**  Any clearly inefficient or repeated code should be optimized or refactored.
princemaple: Thanks for the PR. It looks great 😃 I'll probably merge it in a couple of days, when I get the time to go through it in depth.

Patch:
diff --git a/README.md b/README.md
index 351ffde..e749d7f 100644
--- a/README.md
+++ b/README.md
@@ -389,6 +389,9 @@ If you are curious, this is how it the mailbox preview looks like:
 
 ![Plug.Swoosh.MailboxPreview](https://github.com/swoosh/swoosh/raw/main/images/mailbox-preview.png)
 
+
+_Note_ : To show the preview we use the cdn-version of Tailwindcss. If you have set a `content-security-policy` you may have to add `https://cdn.tailwindcss.com` to `default-src` to have the correct make up.
+
 The preview is also available as a JSON endpoint.
 
 ```sh
diff --git a/lib/swoosh/adapters/mailtrap.ex b/lib/swoosh/adapters/mailtrap.ex
new file mode 100644
index 0000000..11d849f
--- /dev/null
+++ b/lib/swoosh/adapters/mailtrap.ex
@@ -0,0 +1,212 @@
+defmodule Swoosh.Adapters.Mailtrap do
+  @provider_options_body_fields [
+    :custom_variables,
+    :category
+  ]
+
+  @moduledoc ~s"""
+  An adapter that sends email using the Mailtrap API.
+
+  For reference: [Mailtrap API docs](https://api-docs.mailtrap.io/docs/mailtrap-api-docs/67f1d70aeb62c-send-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.Mailtrap,
+        api_key: "my-api-key"
+
+      # lib/sample/mailer.ex
+      defmodule Sample.Mailer do
+        use Swoosh.Mailer, otp_app: :sample
+      end
+
+  ## Sandbox mode
+
+  For [sandbox mode](https://api-docs.mailtrap.io/docs/mailtrap-api-docs/bcf61cdc1547e-send-email-early-access), use the following config:
+
+      # config/config.exs
+      config :sample, Sample.Mailer,
+        adapter: Swoosh.Adapters.Mailtrap,
+        api_key: "my-api-key",
+        sandbox_inbox_id: '111111'
+
+  ## Using with provider options
+
+      import Swoosh.Email
+
+      new()
+      |> from({"Xu Shang-Chi", "xu.shangchi@example.com"})
+      |> to({"Katy", "katy@example.com"})
+      |> reply_to("xu.xialing@example.com")
+      |> cc("yingli@example.com")
+      |> cc({"Xu Wenwu", "xu.wenwu@example.com"})
+      |> bcc("yingnan@example.com")
+      |> bcc({"Jon Jon", "jonjon@example.com"})
+      |> subject("Hello, Ten Rings!")
+      |> html_body("<h1>Hello</h1>")
+      |> text_body("Hello")
+      |> put_provider_option(:custom_variables, %{
+        my_var: %{my_message_id: 123},
+        my_other_var: %{my_other_id: 1, stuff: 2}
+      })
+      |> put_provider_option(:category, "welcome")
+
+  ## Provider Options
+
+  Supported provider options are the following:
+
+  #### Inserted into request body
+
+    * `:category` (string) - an email category
+
+    * `:custom_variables` (map) - a map contains fields
+
+
+  """
+
+  use Swoosh.Adapter, required_config: [:api_key]
+
+  alias Swoosh.Email
+
+  @base_url "https://send.api.mailtrap.io"
+  @sandbox_base_url "https://sandbox.api.mailtrap.io"
+  @api_endpoint "/api/send"
+
+  @impl true
+  def deliver(%Email{} = email, config \\ []) do
+    headers = [
+      {"Content-Type", "application/json"},
+      {"User-Agent", "swoosh/#{Swoosh.version()}"},
+      {"Authorization", "Bearer #{config[:api_key]}"}
+    ]
+
+    body = email |> prepare_body() |> Swoosh.json_library().encode!
+    url = prepare_url(config)
+
+    case Swoosh.ApiClient.post(url, headers, body, email) do
+      {:ok, code, _headers, body} when code >= 200 and code <= 399 ->
+        {:ok, %{ids: Swoosh.json_library().decode!(body)["message_ids"]}}
+
+      {: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_url(config) do
+    if config[:sandbox_inbox_id] do
+      base_url = config[:base_url] || @sandbox_base_url
+      [base_url, Enum.join([@api_endpoint, config[:sandbox_inbox_id]], "/")]
+    else
+      [base_url(config), @api_endpoint]
+    end
+  end
+
+  defp base_url(config),
+    do:  config[:base_url] || @base_url
+
+  defp prepare_body(email) do
+    %{}
+    |> prepare_from(email)
+    |> prepare_to(email)
+    |> prepare_cc(email)
+    |> prepare_bcc(email)
+    |> prepare_subject(email)
+    |> prepare_text(email)
+    |> prepare_html(email)
+    |> prepare_attachments(email)
+    |> prepare_reply_to(email)
+    |> prepare_custom_headers(email)
+    |> prepare_provider_options_body_fields(email)
+  end
+
+  defp email_item({"", email}), do: %{email: email}
+  defp email_item({name, email}), do: %{email: email, name: name}
+  defp email_item(email), do: %{email: email}
+
+  defp reply_to_item({_, email}), do: email
+  defp reply_to_item(email), do: email
+
+  defp prepare_from(body, %{from: from}),
+    do: Map.put(body, :from, from |> email_item)
+
+  defp prepare_to(body, %{to: to}),
+    do: Map.put(body, :to, to |> Enum.map(&email_item(&1)))
+
+  defp prepare_cc(body, %{cc: []}), do: body
+
+  defp prepare_cc(body, %{cc: cc}),
+    do: Map.put(body, :cc, cc |> Enum.map(&email_item(&1)))
+
+  defp prepare_bcc(body, %{bcc: []}), do: body
+
+  defp prepare_bcc(body, %{bcc: bcc}),
+    do: Map.put(body, :bcc, bcc |> Enum.map(&email_item(&1)))
+
+  defp prepare_subject(body, %{subject: subject}),
+    do: Map.put(body, :subject, subject)
+
+  defp prepare_text(body, %{text_body: nil}), do: body
+
+  defp prepare_text(body, %{text_body: text}),
+    do: Map.put(body, :text, text)
+
+  defp prepare_html(body, %{html_body: nil}), do: body
+
+  defp prepare_html(body, %{html_body: html}),
+    do: Map.put(body, :html, html)
+
+  defp prepare_attachments(body, %{attachments: []}), do: body
+
+  defp prepare_attachments(body, %{attachments: attachments}) do
+    attachments =
+      Enum.map(attachments, fn attachment ->
+        attachment_info = %{
+          filename: attachment.filename,
+          type: attachment.content_type,
+          content: Swoosh.Attachment.get_content(attachment, :base64)
+        }
+
+        extra =
+          case attachment.type do
+            :inline -> %{disposition: "inline", content_id: attachment.filename}
+            :attachment -> %{disposition: "attachment"}
+          end
+
+        Map.merge(attachment_info, extra)
+      end)
+
+    Map.put(body, :attachments, attachments)
+  end
+
+  defp prepare_reply_to(body, %{reply_to: nil}), do: body
+
+  defp prepare_reply_to(body, %{reply_to: reply_to}) do
+    Map.put(body, :headers, %{"Reply-To" => reply_to_item(reply_to)})
+  end
+
+  defp prepare_custom_headers(body, %{headers: headers})
+       when map_size(headers) == 0,
+       do: body
+
+  defp prepare_custom_headers(%{headers: body_headers} = body, %{headers: headers}) do
+    Map.put(body, :headers, Map.merge(headers, body_headers))
+  end
+
+  defp prepare_custom_headers(body, %{headers: headers}),
+    do: Map.put(body, :headers, headers)
+
+  defp prepare_provider_options_body_fields(body, %{provider_options: provider_options}) do
+    Map.merge(body, Map.take(provider_options, @provider_options_body_fields))
+  end
+end


Test patch:
diff --git a/test/integration/adapters/mailtrap_test.exs b/test/integration/adapters/mailtrap_test.exs
new file mode 100644
index 0000000..7084623
--- /dev/null
+++ b/test/integration/adapters/mailtrap_test.exs
@@ -0,0 +1,49 @@
+defmodule Swoosh.Integration.Adapters.MailtrapTest do
+  use ExUnit.Case, async: true
+
+  import Swoosh.Email
+
+  @moduletag integration: true
+
+  setup_all do
+    config = [
+      api_key: System.get_env("MAILTRAP_API_KEY"),
+      domain: System.get_env("MAILTRAP_DOMAIN"),
+      destination_domain: System.get_env("MAILTRAP_DEST_DOMAIN")
+    ]
+
+    {:ok, config: config}
+  end
+
+  test "simple deliver", %{config: config} do
+    email =
+      new()
+      |> from({"Swoosh Mailtrap", "swoosh+mailtrap@#{config[:domain]}"})
+      |> reply_to("swoosh+replyto@#{config[:domain]}")
+      |> to("swoosh+to@#{config[:destination_domain]}")
+      |> cc("swoosh+cc@#{config[:destination_domain]}")
+      |> bcc("swoosh+bcc@#{config[:destination_domain]}")
+      |> subject("Swoosh - Mailtrap 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>")
+
+    assert {:ok, _response} = Swoosh.Adapters.Mailtrap.deliver(email, config)
+  end
+
+  test "sandbox deliver", %{config: config} do
+    email =
+      new()
+      |> from({"Swoosh Mailtrap", "swoosh+mailtrap@#{config[:domain]}"})
+      |> reply_to("swoosh+replyto@#{config[:domain]}")
+      |> to("swoosh+to@#{config[:destination_domain]}")
+      |> cc("swoosh+cc@#{config[:destination_domain]}")
+      |> bcc("swoosh+bcc@#{config[:destination_domain]}")
+      |> subject("Swoosh - Mailtrap 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>")
+
+    sandbox_config = [sandbox_inbox_id: System.get_env("MAILTRAP_INBOX")]
+    assert {:ok, _response} =
+      Swoosh.Adapters.Mailtrap.deliver(email, config ++ sandbox_config)
+  end
+end
diff --git a/test/swoosh/adapters/mailtrap_test.exs b/test/swoosh/adapters/mailtrap_test.exs
new file mode 100644
index 0000000..a967d97
--- /dev/null
+++ b/test/swoosh/adapters/mailtrap_test.exs
@@ -0,0 +1,440 @@
+defmodule Swoosh.Adapters.MailtrapTest do
+  use Swoosh.AdapterCase, async: true
+
+  import Swoosh.Email
+  alias Swoosh.Adapters.Mailtrap
+
+  @success_response """
+    {
+      "success": true,
+      "message_ids": [
+        "0c7fd939-02cf-11ed-88c2-0a58a9feac02"
+      ]
+    }
+  """
+
+  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@example.com")
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+      |> text_body("Hello")
+
+    {: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 = %{
+        "from" => %{"email" => "tony.stark@example.com"},
+        "to" => [%{"email" => "steve.rogers@example.com"}],
+        "text" => "Hello",
+        "html" => "<h1>Hello</h1>",
+        "subject" => "Hello, Avengers!"
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/send" == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  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(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => %{"email" => "tony.stark@example.com"},
+        "to" => [%{"email" => "steve.rogers@example.com"}],
+        "text" => "Hello",
+        "subject" => "Hello, Avengers!"
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/send" == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  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(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => %{"email" => "tony.stark@example.com"},
+        "to" => [%{"email" => "steve.rogers@example.com"}],
+        "html" => "<h1>Hello</h1>",
+        "subject" => "Hello, Avengers!"
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/send" == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  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(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => %{"name" => "T Stark", "email" => "tony.stark@example.com"},
+        "headers" => %{"Reply-To" => "hulk.smash@example.com"},
+        "to" => [%{"name" => "Steve Rogers", "email" => "steve.rogers@example.com"}],
+        "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!"
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/send" == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  end
+
+  test "deliver/1 with custom variables 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")
+      |> put_provider_option(:custom_variables, %{
+        my_var: %{my_message_id: 123},
+        my_other_var: %{my_other_id: 1, stuff: 2}
+      })
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => %{"name" => "T Stark", "email" => "tony.stark@example.com"},
+        "headers" => %{"Reply-To" => "hulk.smash@example.com"},
+        "to" => [%{"name" => "Steve Rogers", "email" => "steve.rogers@example.com"}],
+        "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!",
+        "custom_variables" => %{
+          "my_var" => %{"my_message_id" => 123},
+          "my_other_var" => %{"stuff" => 2, "my_other_id" => 1}
+        }
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/send" == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  end
+
+  test "deliver/1 with category returns :ok", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from({"T Stark", "tony.stark@example.com"})
+      |> to({"Steve Rogers", "steve.rogers@example.com"})
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+      |> text_body("Hello")
+      |> put_provider_option(:category, "alert")
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => %{"name" => "T Stark", "email" => "tony.stark@example.com"},
+        "to" => [%{"name" => "Steve Rogers", "email" => "steve.rogers@example.com"}],
+        "text" => "Hello",
+        "html" => "<h1>Hello</h1>",
+        "subject" => "Hello, Avengers!",
+        "category" => "alert"
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/send" == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  end
+
+  test "deliver/1 with custom headers returns :ok", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from({"T Stark", "tony.stark@example.com"})
+      |> to({"Steve Rogers", "steve.rogers@example.com"})
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+      |> text_body("Hello")
+      |> header("In-Reply-To", "<1234@example.com>")
+      |> header("X-Accept-Language", "en")
+      |> header("X-Mailer", "swoosh")
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => %{"name" => "T Stark", "email" => "tony.stark@example.com"},
+        "to" => [%{"name" => "Steve Rogers", "email" => "steve.rogers@example.com"}],
+        "text" => "Hello",
+        "html" => "<h1>Hello</h1>",
+        "subject" => "Hello, Avengers!",
+        "headers" => %{
+          "In-Reply-To" => "<1234@example.com>",
+          "X-Accept-Language" => "en",
+          "X-Mailer" => "swoosh"
+        }
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/send" == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  end
+
+  test "deliver/1 with reply_to and custom headers returns :ok", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from({"T Stark", "tony.stark@example.com"})
+      |> to({"Steve Rogers", "steve.rogers@example.com"})
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+      |> text_body("Hello")
+      |> reply_to("hulk.smash@example.com")
+      |> header("In-Reply-To", "<1234@example.com>")
+      |> header("X-Accept-Language", "en")
+      |> header("X-Mailer", "swoosh")
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => %{"name" => "T Stark", "email" => "tony.stark@example.com"},
+        "to" => [%{"name" => "Steve Rogers", "email" => "steve.rogers@example.com"}],
+        "text" => "Hello",
+        "html" => "<h1>Hello</h1>",
+        "subject" => "Hello, Avengers!",
+        "headers" => %{
+          "Reply-To" => "hulk.smash@example.com",
+          "In-Reply-To" => "<1234@example.com>",
+          "X-Accept-Language" => "en",
+          "X-Mailer" => "swoosh"
+        }
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/send" == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  end
+
+  test "deliver/1 with an attachment", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from("tony.stark@example.com")
+      |> to("steve.rogers@example.com")
+      |> subject("Hello, Avengers!")
+      |> text_body("Hello")
+      |> attachment("test/support/attachment.txt")
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      attachment_content =
+        "test/support/attachment.txt"
+        |> File.read!()
+        |> Base.encode64()
+
+      body_params = %{
+        "from" => %{"email" => "tony.stark@example.com"},
+        "to" => [%{"email" => "steve.rogers@example.com"}],
+        "text" => "Hello",
+        "subject" => "Hello, Avengers!",
+        "attachments" => [
+          %{
+            "filename" => "attachment.txt",
+            "content" => attachment_content,
+            "type" => "text/plain",
+            "disposition" => "attachment"
+          }
+        ]
+      }
+      assert body_params == conn.body_params
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  end
+
+  test "deliver/1 with sandbox config 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>")
+      |> text_body("Hello")
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => %{"email" => "tony.stark@example.com"},
+        "to" => [%{"email" => "steve.rogers@example.com"}],
+        "text" => "Hello",
+        "html" => "<h1>Hello</h1>",
+        "subject" => "Hello, Avengers!"
+      }
+
+      assert body_params == conn.body_params
+      assert "/api/send/11111" == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert Mailtrap.deliver(email, config ++ [{:sandbox_inbox_id, 11111}]) ==
+             {:ok, %{ids: ["0c7fd939-02cf-11ed-88c2-0a58a9feac02"]}}
+  end
+
+  test "deliver/1 with 400 response", %{bypass: bypass, config: config, valid_email: email} do
+    errors = "{\"errors\": [\"bla bla\"],\"success\": false}"
+
+    Bypass.expect(bypass, &Plug.Conn.resp(&1, 400, errors))
+
+    response =
+      {:error, {400, %{"errors" => ["bla bla"], "success" => false}}}
+
+    assert Mailtrap.deliver(email, config) == response
+  end
+
+  test "deliver/1 with 4xx response", %{bypass: bypass, config: config, valid_email: email} do
+    errors = "{\"errors\": [\"bla bla\"],\"success\": false}"
+
+    Bypass.expect(bypass, &Plug.Conn.resp(&1, 400, errors))
+
+    response =
+      {:error, {400, %{"errors" => ["bla bla"], "success" => false}}}
+
+    assert Mailtrap.deliver(email, config) == response
+  end
+
+  test "deliver/1 with 5xx response", %{bypass: bypass, config: config, valid_email: email} do
+    Bypass.expect(bypass, fn conn ->
+      assert "/api/send" == conn.request_path
+      assert "POST" == conn.method
+      Plug.Conn.resp(conn, 500, "")
+    end)
+
+    assert Mailtrap.deliver(email, config) == {:error, {500, ""}}
+  end
+
+  test "validate_config/1 with valid config", %{config: config} do
+    assert Mailtrap.validate_config(config) == :ok
+  end
+
+  test "validate_config/1 with invalid config" do
+    assert_raise ArgumentError,
+                 """
+                 expected [:api_key] to be set, got: []
+                 """,
+                 fn ->
+                   Mailtrap.validate_config([])
+                 end
+  end
+end