Repository: swoosh/swoosh
Pull request #1047 (2025-06-11 01:35:12)

Escape quotes and backslashes in address names
Hello! I apologize for the unprompted PR.
At work, we are currently investigating switching from Bamboo to Swoosh, and we ran into a specific issue that we thing we can solve.

Some of our users have unconventional names, and we've ran into encoding issues before with SES, in particular with address names.

This PR adds a step in the recipient rendering to escape emails and backslashes to address encoding concerns.

[RFC 5322](https://datatracker.ietf.org/doc/html/rfc5322) defines the following syntax for address names as (with only the relevant bits)
```
name-addr       =   [display-name] angle-addr (Section 3.4)
display-name    =   phrase (Section 3.4)
phrase          =   1*word / obs-phrase (Section 3.2.5)
word            =   atom / quoted-string (Section 3.2.5)
quoted-string   = ...  (Section 3.2.4)
```
which mentions
> Also note that since quoted-pair is allowed in a quoted-string, the quote and backslash characters may appear in a quoted-string so long as they appear as a quoted-pair.

Currently, `:mimemail.encode` has some quirks, it lets backslashes through, it lets some quotation marks through (I think when they are in pair), but not always:
```elixir
iex(7)> Email.new() |> Email.from({"hi\\", "a@test.com"}) |> Email.text_body("hello") |> Swoosh.Adapters.SMTP.Helpers.body([])
"Content-Type: text/plain;\r\n\t charset=\"utf-8\"\r\nFrom: \"hi\\\\\" <a@test.com>\r\nSubject: \r\nMIME-Version: 1.0\r\nDate: Wed, 11 Jun 2025 10:24:46 +0900\r\nMessage-ID: <a8025f15691d5722be30ea8e3f5d4fda@jie>\r\n\r\nhello"

iex(8)> Email.new() |> Email.from({"a \"b\" c", "a@test.com"}) |> Email.text_body("hello") |> Swoosh.Adapters.SMTP.Helpers.body([])
"Content-Type: text/plain;\r\n\t charset=\"utf-8\"\r\nFrom: \"a \\\"b\\\" c\" <a@test.com>\r\nSubject: \r\nMIME-Version: 1.0\r\nDate: Wed, 11 Jun 2025 10:25:43 +0900\r\nMessage-ID: <f606eed1a89dafd6a7346173298696a8@jie>\r\n\r\nhello"

iex(9)> Email.new() |> Email.from({"a \"b\"", "a@test.com"}) |> Email.text_body("hello") |> Swoosh.Adapters.SMTP.Helpers.body([])
** (MatchError) no match of right hand side value: {:error, {1, :smtp_rfc5322_scan, {:illegal, ~c"\"\""}}}
    (gen_smtp 1.3.0) /Users/jie/Documents/work/steady/code/swoosh/deps/gen_smtp/src/mimemail.erl:1184: :mimemail.encode_header_value/2
    (gen_smtp 1.3.0) /Users/jie/Documents/work/steady/code/swoosh/deps/gen_smtp/src/mimemail.erl:1148: :mimemail.encode_headers/1
    (gen_smtp 1.3.0) /Users/jie/Documents/work/steady/code/swoosh/deps/gen_smtp/src/mimemail.erl:1149: :mimemail.encode_headers/1
    (gen_smtp 1.3.0) /Users/jie/Documents/work/steady/code/swoosh/deps/gen_smtp/src/mimemail.erl:210: :mimemail.encode/2

iex(10)> Email.new() |> Email.from({"\"", "a@test.com"}) |> Email.text_body("hello") |> Swoosh.Adapters.SMTP.Helpers.body([])
** (MatchError) no match of right hand side value: {:error, {1, :smtp_rfc5322_scan, {:illegal, ~c"\"\""}}}
    (gen_smtp 1.3.0) /Users/jie/Documents/work/steady/code/swoosh/deps/gen_smtp/src/mimemail.erl:1184: :mimemail.encode_header_value/2
    (gen_smtp 1.3.0) /Users/jie/Documents/work/steady/code/swoosh/deps/gen_smtp/src/mimemail.erl:1148: :mimemail.encode_headers/1
    (gen_smtp 1.3.0) /Users/jie/Documents/work/steady/code/swoosh/deps/gen_smtp/src/mimemail.erl:1149: :mimemail.encode_headers/1
    (gen_smtp 1.3.0) /Users/jie/Documents/work/steady/code/swoosh/deps/gen_smtp/src/mimemail.erl:210: :mimemail.encode/2
```

We have had emails not delivered on SES of the form `{"hi\\", "a@test.com"})`, so even if mimemail accepts the format, I believe we should escape it.

Let me know if I can make any changes to improve the PR. 

princemaple: I'd love the expected text in tests in `~s||` form to save some future headaches when reading the code 🥲 other than that I'm 👍 
jiegillet: \\\\\\excellent point\\\\\\\\

Patch:
diff --git a/lib/swoosh/email/render.ex b/lib/swoosh/email/render.ex
index 963e36d..0f0b201 100644
--- a/lib/swoosh/email/render.ex
+++ b/lib/swoosh/email/render.ex
@@ -4,9 +4,17 @@ defmodule Swoosh.Email.Render do
   def render_recipient(nil), do: ""
   def render_recipient({nil, address}), do: address
   def render_recipient({"", address}), do: address
-  def render_recipient({name, address}), do: ~s("#{name}" <#{address}>)
   def render_recipient([]), do: ""
 
+  def render_recipient({name, address}) do
+    name =
+      name
+      |> String.replace("\\", "\\\\")
+      |> String.replace("\"", "\\\"")
+
+    ~s("#{name}" <#{address}>)
+  end
+
   def render_recipient(list) when is_list(list) do
     list
     |> Enum.map_join(", ", &render_recipient/1)


Test patch:
diff --git a/test/swoosh/adapters/amazonses_test.exs b/test/swoosh/adapters/amazonses_test.exs
index aa85a54..ba8fcd1 100644
--- a/test/swoosh/adapters/amazonses_test.exs
+++ b/test/swoosh/adapters/amazonses_test.exs
@@ -163,6 +163,34 @@ defmodule Swoosh.Adapters.AmazonSESTest do
     assert AmazonSES.deliver(email, config) == {:ok, %{id: "messageId", request_id: "requestId"}}
   end
 
+  test "deliver/1 with quotes and backslashes in address name", %{bypass: bypass, config: config} do
+    email =
+      new()
+      |> from({~s|G Threepwood, "Mighty Pirate"|, "guybrush.threepwood@pirates.grog"})
+      |> to({~s|Elaine Marley\\|, "elaine.marley@triisland.gov"})
+      |> text_body("Hello")
+
+    Bypass.expect(bypass, fn conn ->
+      conn = parse(conn)
+
+      {:ok, raw_message} = conn.body_params["RawMessage.Data"] |> URI.decode() |> Base.decode64()
+
+      assert String.contains?(
+               raw_message,
+               ~s|From: "G Threepwood, \\"Mighty Pirate\\"" <guybrush.threepwood@pirates.grog>\r\n|
+             )
+
+      assert String.contains?(
+               raw_message,
+               ~s|To: "Elaine Marley\\\\" <elaine.marley@triisland.gov>\r\n|
+             )
+
+      Plug.Conn.resp(conn, 200, @success_response)
+    end)
+
+    assert AmazonSES.deliver(email, config) == {:ok, %{id: "messageId", request_id: "requestId"}}
+  end
+
   test "optional config params are present in the API request body when they're set in the config",
        %{
          bypass: bypass,
diff --git a/test/swoosh/email/smtp_test.exs b/test/swoosh/email/smtp_test.exs
index ff07019..8838816 100644
--- a/test/swoosh/email/smtp_test.exs
+++ b/test/swoosh/email/smtp_test.exs
@@ -105,6 +105,30 @@ defmodule Swoosh.Email.SMTPTest do
               ], "Hello"}
   end
 
+  test "simple email with quotes and backslashes in the recipient names", %{valid_email: email} do
+    email =
+      email
+      |> html_body(nil)
+      |> from({~s|Tony "Iron Man" Stark|, "tony@stark.com"})
+      |> to({~s|Steve "Cap" Rogers|, "steve@rogers.com"})
+      |> cc({~s|\\Loki\\|, "loki@jotunheim.god"})
+
+    assert Helpers.prepare_message(email, []) ==
+             {
+               "text",
+               "plain",
+               [
+                 {"Content-Type", "text/plain; charset=\"utf-8\""},
+                 {"From", ~s|"Tony \\"Iron Man\\" Stark" <tony@stark.com>|},
+                 {"To", ~s|"Steve \\"Cap\\" Rogers" <steve@rogers.com>, steve@rogers.com|},
+                 {"Cc", ~s|"\\\\Loki\\\\" <loki@jotunheim.god>|},
+                 {"Subject", "Hello, Avengers!"},
+                 {"MIME-Version", "1.0"}
+               ],
+               "Hello"
+             }
+  end
+
   test "simple html email", %{valid_email: email} do
     email = email |> text_body(nil)