Repository: swoosh/swoosh
Pull request #971 (2024-11-12 10:34:10)

Sendgrid: Support Mail Body Compression
Heya,
Sendgrid supports sending in gzip compressed bodies. (see [here](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send#mail-body-compression))

This PR adds an option for that to the Adapter.

I'm somewhat unsure how to properly test this without duplicating most of the existing testsuite, so any pointers would be welcome here :)

Also, if there is a less invasive way to set the headers/compress the body I'm happy to make adjustments as needed.

princemaple: Since `bypass` is a proper plug server, I think we can simply test if the header is expected, and the content is correctly parsed by the server, when compress is set to `true`. We don't need to test every scenario in a compressed setting.
luhagel: Gonna need some more time with this, we'll need a custom Plug Parser for, should find some time the next few days 👍 
kevinam99: @luhagel - are you still working on this? I have some bandwidth and I think I can help. I'll appreciate any earlier context you have on this :)
luhagel: @kevinam99 Heya, 
planning to over the holidays - the last few weeks were pretty rough work-wise ^^

I'll gladly accept any help you can spare :)
kevinam99: @luhagel okay cool, let me take a shot at this
kevinam99: Okay, I think I have something here. Noticed a weird behaviour with the parsers https://github.com/luhagel/swoosh/pull/1

Had to add a check for the gzip parser in conn_parser based on the content-encoding header because when I used it in the regular list of parsers, the `parse` callback wouldn't get invoked at all leading the test to fail. If I add the Gzip parser at the top of the list, only this one test would pass
kevinam99: Never mind. Managed to fix the problem :)
princemaple: Hi all, turned out that a custom parser wasn't the correct way to go. I changed it to use a custom body reader. I think we are all good now.

Patch:
diff --git a/lib/swoosh/adapters/sendgrid.ex b/lib/swoosh/adapters/sendgrid.ex
index 57e9c7f..3bf7dfe 100644
--- a/lib/swoosh/adapters/sendgrid.ex
+++ b/lib/swoosh/adapters/sendgrid.ex
@@ -29,7 +29,8 @@ defmodule Swoosh.Adapters.Sendgrid do
       # config/config.exs
       config :sample, Sample.Mailer,
         adapter: Swoosh.Adapters.Sendgrid,
-        api_key: "my-api-key"
+        api_key: "my-api-key",
+        compress: true # default false
 
       # lib/sample/mailer.ex
       defmodule Sample.Mailer do
@@ -119,13 +120,20 @@ defmodule Swoosh.Adapters.Sendgrid do
   @api_endpoint "/mail/send"
 
   def deliver(%Email{} = email, config \\ []) do
-    headers = [
-      {"Content-Type", "application/json"},
-      {"User-Agent", "swoosh/#{Swoosh.version()}"},
-      {"Authorization", "Bearer #{config[:api_key]}"}
-    ]
+    headers =
+      [
+        {"Content-Type", "application/json"},
+        {"User-Agent", "swoosh/#{Swoosh.version()}"},
+        {"Authorization", "Bearer #{config[:api_key]}"}
+      ]
+      |> maybe_set_content_encoding(config[:compress])
+
+    body =
+      email
+      |> prepare_body()
+      |> Swoosh.json_library().encode!
+      |> maybe_compress_body(config[:compress])
 
-    body = email |> prepare_body() |> Swoosh.json_library().encode!
     url = [base_url(config), @api_endpoint]
 
     case Swoosh.ApiClient.post(url, headers, body, email) do
@@ -143,6 +151,14 @@ defmodule Swoosh.Adapters.Sendgrid do
     end
   end
 
+  defp maybe_set_content_encoding(headers, _compress = true),
+    do: headers ++ [{"Content-Encoding", "gzip"}]
+
+  defp maybe_set_content_encoding(headers, _), do: headers
+
+  defp maybe_compress_body(body, _compress = true), do: :zlib.gzip(body)
+  defp maybe_compress_body(body, _), do: body
+
   defp extract_id(headers) do
     headers
     |> Enum.map(fn {k, v} -> {String.downcase(k), v} end)


Test patch:
diff --git a/test/support/conn_parser.ex b/test/support/conn_parser.ex
index 8930643..70313a4 100644
--- a/test/support/conn_parser.ex
+++ b/test/support/conn_parser.ex
@@ -10,6 +10,7 @@ defmodule Swoosh.ConnParser do
       ])
       |> Keyword.put_new(:json_decoder, Swoosh.json_library())
       |> Keyword.put_new(:pass, ["message/rfc822", "text/plain"])
+      |> Keyword.put_new(:body_reader, {GZipBodyReader, :read_body, []})
 
     Plug.Parsers.call(conn, Plug.Parsers.init(opts))
   end
diff --git a/test/support/gzip_body_reader.ex b/test/support/gzip_body_reader.ex
new file mode 100644
index 0000000..f8e802c
--- /dev/null
+++ b/test/support/gzip_body_reader.ex
@@ -0,0 +1,22 @@
+defmodule GZipBodyReader do
+  def read_body(conn, _opts) do
+    {:ok, body, conn} = Plug.Conn.read_body(conn)
+
+    case get_content_encoding(conn) do
+      "gzip" ->
+        uncompressed_body = :zlib.gunzip(body)
+        conn = put_in(conn.assigns[:raw_body], [uncompressed_body])
+        {:ok, uncompressed_body, conn}
+
+      _ ->
+        {:ok, body, conn}
+    end
+  end
+
+  defp get_content_encoding(conn) do
+    conn
+    |> Plug.Conn.get_req_header("content-encoding")
+    |> List.first("")
+    |> String.downcase()
+  end
+end
diff --git a/test/swoosh/adapters/sendgrid_test.exs b/test/swoosh/adapters/sendgrid_test.exs
index 23bb951..a1d1750 100644
--- a/test/swoosh/adapters/sendgrid_test.exs
+++ b/test/swoosh/adapters/sendgrid_test.exs
@@ -25,6 +25,37 @@ defmodule Swoosh.Adapters.SendgridTest do
     |> Plug.Conn.resp(200, body)
   end
 
+  test "successful delivery returns :ok for gzip emails", %{
+    bypass: bypass,
+    config: config,
+    valid_email: email
+  } do
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      body_params = %{
+        "from" => %{"email" => "tony.stark@example.com"},
+        "personalizations" => [%{"to" => [%{"email" => "steve.rogers@example.com"}]}],
+        "content" => [
+          %{"type" => "text/plain", "value" => "Hello"},
+          %{"type" => "text/html", "value" => "<h1>Hello</h1>"}
+        ],
+        "subject" => "Hello, Avengers!"
+      }
+
+      assert body_params == conn.body_params
+      assert "/mail/send" == conn.request_path
+      assert "POST" == conn.method
+      assert {"content-encoding", "gzip"} in conn.req_headers
+      assert body_params == conn.body_params
+
+      respond_with(conn, body: "{\"message\":\"success\"}", id: "123-xyz")
+    end)
+
+    config = Keyword.put(config, :compress, true)
+    assert Sendgrid.deliver(email, config) == {:ok, %{id: "123-xyz"}}
+  end
+
   test "successful delivery returns :ok", %{bypass: bypass, config: config, valid_email: email} do
     Bypass.expect(bypass, fn conn ->
       conn = parse(conn)