Repository: swoosh/swoosh
Pull request #815 (2023-09-21 16:29:24)

Implement Swoosh.Adapters.MsGraph
As discussed in https://github.com/swoosh/swoosh/issues/744.

princemaple: One last thing, is plug needed for this adapter? I don't see it being used other than in the tests.
justindotpub: Good catch. I didn’t use it explicitly but assumed it was some requirement under the covers, perhaps due to the use of the api client or something. Let me know I need to make that change. I’ll assume you are otherwise, so I don’t mess up what you’re doing. 
justindotpub: I confirmed, the mailgun adapter that I used as my starting point explicitly used `Plug.Conn.Query.encode`.  I don't need Plug.

Patch:
diff --git a/README.md b/README.md
index 6c39141..d5572ee 100644
--- a/README.md
+++ b/README.md
@@ -136,6 +136,7 @@ included:
 | Mandrill     | [Swoosh.Adapters.Mandrill](https://hexdocs.pm/swoosh/Swoosh.Adapters.Mandrill.html#content)         |            |
 | Mailgun      | [Swoosh.Adapters.Mailgun](https://hexdocs.pm/swoosh/Swoosh.Adapters.Mailgun.html#content)           |            |
 | Mailjet      | [Swoosh.Adapters.Mailjet](https://hexdocs.pm/swoosh/Swoosh.Adapters.Mailjet.html#content)           |            |
+| MsGraph      | [Swoosh.Adapters.MsGraph](https://hexdocs.pm/swoosh/Swoosh.Adapters.MsGraph.html#content)           |            |
 | Postmark     | [Swoosh.Adapters.Postmark](https://hexdocs.pm/swoosh/Swoosh.Adapters.Postmark.html#content)         |            |
 | SparkPost    | [Swoosh.Adapters.SparkPost](https://hexdocs.pm/swoosh/Swoosh.Adapters.SparkPost.html#content)       |            |
 | Amazon SES   | [Swoosh.Adapters.AmazonSES](https://hexdocs.pm/swoosh/Swoosh.Adapters.AmazonSES.html#content)       |            |
diff --git a/lib/swoosh/adapters/ms_graph.ex b/lib/swoosh/adapters/ms_graph.ex
new file mode 100644
index 0000000..18a7ca0
--- /dev/null
+++ b/lib/swoosh/adapters/ms_graph.ex
@@ -0,0 +1,114 @@
+defmodule Swoosh.Adapters.MsGraph do
+  @moduledoc ~S"""
+  An adapter that sends email using the Microsoft Graph API.
+
+  For reference: [Microsoft Graph API docs](https://learn.microsoft.com/en-us/graph/api/user-sendmail)
+
+  **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.
+
+  ## Dependency
+
+  Microsoft Graph adapter requires `:gen_smtp` to work properly.
+  `:gen_smtp` is only used to encode the email body to MIME format.
+
+  ## Configuration options
+
+  * `:auth` - either a function, a {mod, func, args} tuple, or a string that returns/is an OAuth 2.0 access token.
+  * `:base_url` - the base URL to use as the Microsoft Graph API endpoint.  Defaults to the standard Microsoft Graph API endpoint.
+
+  ## Example
+
+      # config/config.exs
+      config :sample, Sample.Mailer,
+        adapter: Swoosh.Adapters.MsGraph,
+        auth: fn -> Sample.OAuthTokenRequester.request_token() end
+
+      # lib/sample/mailer.ex
+      defmodule Sample.Mailer do
+        use Swoosh.Mailer, otp_app: :sample
+      end
+
+  """
+
+  use Swoosh.Adapter,
+    required_config: [:auth],
+    required_deps: [:gen_smtp]
+
+  require Logger
+  alias Swoosh.Email
+
+  @base_url "https://graph.microsoft.com/v1.0"
+
+  @impl true
+  def deliver(%Email{} = email, config \\ []) do
+    Logger.debug("Delivering email using #{__MODULE__} Swoosh adapter: #{inspect(email)}")
+    headers = prepare_headers(config)
+    url = api_endpoint_url(email, config)
+    body = encode_body(email, config)
+
+    case Swoosh.ApiClient.post(url, headers, body, email) do
+      # Per https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0&tabs=http#response
+      # if successful, this method returns 202 Accepted response code. It doesn't return anything in the response body.
+      {:ok, 202, _headers, body} ->
+        Logger.debug("202 response, body: #{inspect(body)}")
+        {:ok, %{}}
+
+      # If the request body includes malformed MIME content, this method returns 400 Bad request and the following error message: "Invalid base64 string for MIME content."
+      {:ok, 400, _headers, body} ->
+        Logger.debug("400 response, body: #{inspect(body)}")
+        {:error, {400, body}}
+
+      # No other return codes are documented, but we'll treat them as an error.
+      {:ok, code, _headers, body} ->
+        Logger.debug("Unexpected response, code: #{code}, body: #{inspect(body)}")
+        {:error, {code, body}}
+
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+
+  @doc false
+  # Encodes the email to headers and MIME content, encoded in base64,
+  # per https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0&tabs=http#request-body.
+  def encode_body(email, config) do
+    email
+    |> Swoosh.Adapters.SMTP.Helpers.body(config)
+    |> Base.encode64()
+  end
+
+  defp base_url(config), do: config[:base_url] || @base_url
+
+  defp api_endpoint_url(email, config) do
+    {_, from_email} = email.from
+    "#{base_url(config)}/users/#{from_email}/sendMail"
+  end
+
+  defp prepare_headers(config) do
+    [
+      {"User-Agent", "swoosh/#{Swoosh.version()}"},
+      {"Authorization", "Bearer #{auth(config)}"},
+      # Per https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0&tabs=http#request-headers
+      # we use a Content-Type of `text/plain` for MIME content.
+      {"Content-Type", "text/plain"}
+    ]
+  end
+
+  defp auth(config) do
+    case config[:auth] do
+      func when is_function(func, 0) ->
+        func.()
+
+      {m, f, a} when is_atom(m) and is_atom(f) and is_list(a) ->
+        apply(m, f, a)
+
+      token when is_binary(token) ->
+        token
+
+      nil ->
+        raise "Missing required config :auth, it can be a function, a {mod, func, args} tuple, or a string"
+    end
+  end
+end


Test patch:
diff --git a/test/support/conn_parser.ex b/test/support/conn_parser.ex
index 65e2646..5538be5 100644
--- a/test/support/conn_parser.ex
+++ b/test/support/conn_parser.ex
@@ -8,7 +8,7 @@ defmodule Swoosh.ConnParser do
         Plug.Parsers.JSON
       ])
       |> Keyword.put_new(:json_decoder, Swoosh.json_library())
-      |> Keyword.put_new(:pass, ["message/rfc822"])
+      |> Keyword.put_new(:pass, ["message/rfc822", "text/plain"])
 
     Plug.Parsers.call(conn, Plug.Parsers.init(opts))
   end
diff --git a/test/swoosh/adapters/ms_graph_test.exs b/test/swoosh/adapters/ms_graph_test.exs
new file mode 100644
index 0000000..2e170f2
--- /dev/null
+++ b/test/swoosh/adapters/ms_graph_test.exs
@@ -0,0 +1,217 @@
+defmodule Swoosh.Adapters.MsGraphTest do
+  use Swoosh.AdapterCase, async: true
+
+  import Swoosh.Email
+  alias Swoosh.Adapters.MsGraph
+
+  @success_response ""
+
+  setup do
+    bypass = Bypass.open()
+
+    config = [
+      base_url: "http://localhost:#{bypass.port}",
+      auth: fn -> "fake-token" end
+    ]
+
+    valid_email =
+      new()
+      |> from("tony.stark@example.com")
+      |> to("steve.rogers@example.com")
+      |> subject("Hello, Avengers!")
+      |> html_body("<h1>Hello</h1>")
+
+    {:ok, bypass: bypass, valid_email: valid_email, config: config}
+  end
+
+  test "a sent email results in :ok", %{bypass: bypass, config: config, valid_email: email} do
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      from_email = email.from |> elem(1)
+      expected_path = "/users/" <> from_email <> "/sendMail"
+
+      {:ok, body, conn} = Plug.Conn.read_body(conn, [])
+      decoded_body = Base.decode64!(body)
+
+      parts = decoded_body |> String.split("\r\n")
+
+      assert "Content-Type: text/html;" in parts
+      assert "From: tony.stark@example.com" in parts
+      assert "To: steve.rogers@example.com" in parts
+      assert "Subject: Hello, Avengers!" in parts
+
+      assert expected_path == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 202, @success_response)
+    end)
+
+    assert MsGraph.deliver(email, config) ==
+             {:ok, %{}}
+  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")
+
+    from_email = email.from |> elem(1)
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+      expected_path = "/users/" <> from_email <> "/sendMail"
+
+      {:ok, body, conn} = Plug.Conn.read_body(conn, [])
+      decoded_body = Base.decode64!(body)
+
+      parts = decoded_body |> String.split("\r\n")
+
+      assert "From: T Stark <tony.stark@example.com>" in parts
+
+      to = Enum.find(parts, fn part -> String.starts_with?(part, "To:") end)
+      assert String.contains?(to, "wasp.avengers@example.com")
+      assert String.contains?(to, "Steve Rogers <steve.rogers@example.com>")
+
+      cc = Enum.find(parts, fn part -> String.starts_with?(part, "Cc:") end)
+      assert String.contains?(cc, "thor.odinson@example.com")
+      assert String.contains?(cc, "Bruce Banner <hulk.smash@example.com>")
+
+      assert "Reply-To: office.avengers@example.com" in parts
+      assert "Subject: Hello, Avengers!" in parts
+      assert "Content-Type: text/html;" in parts
+      assert "Content-Type: text/plain;" in parts
+      assert "<h1>Hello</h1>" in parts
+      assert "Hello" in parts
+
+      assert expected_path == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 202, @success_response)
+    end)
+
+    assert MsGraph.deliver(email, config) ==
+             {:ok, %{}}
+  end
+
+  test "deliver/1 with custom headers 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>")
+      |> header("In-Reply-To", "<1234@example.com>")
+      |> header("X-Accept-Language", "en")
+      |> header("X-Mailer", "swoosh")
+
+    from_email = email.from |> elem(1)
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+      expected_path = "/users/" <> from_email <> "/sendMail"
+
+      {:ok, body, conn} = Plug.Conn.read_body(conn, [])
+      decoded_body = Base.decode64!(body)
+
+      parts = decoded_body |> String.split("\r\n")
+
+      assert "In-Reply-To: <1234@example.com>" in parts
+      assert "X-Accept-Language: en" in parts
+      assert "X-Mailer: swoosh" in parts
+
+      assert expected_path == conn.request_path
+      assert "POST" == conn.method
+
+      Plug.Conn.resp(conn, 202, @success_response)
+    end)
+
+    assert MsGraph.deliver(email, config) ==
+             {:ok, %{}}
+  end
+
+  test "deliver/1 with 4xx response", %{bypass: bypass, config: config, valid_email: email} do
+    Bypass.expect(bypass, fn conn ->
+      Plug.Conn.resp(conn, 401, "Forbidden")
+    end)
+
+    assert MsGraph.deliver(email, config) == {:error, {401, "Forbidden"}}
+  end
+
+  test "validate_config/1 with valid config", %{config: config} do
+    assert MsGraph.validate_config(config) == :ok
+  end
+
+  test "validate_config/1 with invalid config" do
+    assert_raise ArgumentError,
+                 """
+                 expected [:auth] to be set, got: []
+                 """,
+                 fn ->
+                   MsGraph.validate_config([])
+                 end
+  end
+
+  test ":auth config as anonymous fn succeeds", %{
+    bypass: bypass,
+    config: config,
+    valid_email: email
+  } do
+    config = config |> Keyword.put(:auth, fn -> "fake-token-from-fn" end)
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      assert {"authorization", "Bearer fake-token-from-fn"} in conn.req_headers
+
+      Plug.Conn.resp(conn, 202, @success_response)
+    end)
+
+    assert MsGraph.deliver(email, config) ==
+             {:ok, %{}}
+  end
+
+  test ":auth config as string succeeds", %{bypass: bypass, config: config, valid_email: email} do
+    config = config |> Keyword.put(:auth, "fake-token-from-string")
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      assert {"authorization", "Bearer fake-token-from-string"} in conn.req_headers
+
+      Plug.Conn.resp(conn, 202, @success_response)
+    end)
+
+    assert MsGraph.deliver(email, config) ==
+             {:ok, %{}}
+  end
+
+  test ":auth config as MFA tuple succeeds", %{bypass: bypass, config: config, valid_email: email} do
+    config = config |> Keyword.put(:auth, {__MODULE__, :auth, ["a", "b", "c"]})
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      assert {"authorization", "Bearer fake-token-from-mfa-tuple"} in conn.req_headers
+
+      Plug.Conn.resp(conn, 202, @success_response)
+    end)
+
+    assert MsGraph.deliver(email, config) ==
+             {:ok, %{}}
+  end
+
+  def auth(_a, _b, _c) do
+    "fake-token-from-mfa-tuple"
+  end
+end