diff --git a/lib/mail/renderers/rfc_2822.ex b/lib/mail/renderers/rfc_2822.ex index 918b49b9..e42739b0 100644 --- a/lib/mail/renderers/rfc_2822.ex +++ b/lib/mail/renderers/rfc_2822.ex @@ -156,8 +156,16 @@ defmodule Mail.Renderers.RFC2822 do end end - defp render_address({name, email}), - do: "#{encode_header_value(~s("#{name}"), :quoted_printable)} <#{validate_address(email)}>" + defp render_address({name, email}) do + address = validate_address(email) + encoded = encode_header_value(name, :quoted_printable) + + if encoded == name do + ~s("#{name}" <#{address}>) + else + "#{encoded} <#{address}>" + end + end defp render_address(email), do: validate_address(email) @@ -229,10 +237,24 @@ defmodule Mail.Renderers.RFC2822 do defp wrap_encoded_words(value) do :binary.split(value, "=\r\n", [:global]) - |> Enum.map(fn chunk -> <<"=?UTF-8?Q?", chunk::binary, "?=">> end) + |> Enum.map(fn chunk -> + chunk = encode_encoded_word_text(chunk) + <<"=?UTF-8?Q?", chunk::binary, "?=">> + end) |> Enum.join() end + # Per RFC 2047 §5, encoded-words must be recognizable as atoms (RFC 2822 §3.2.4). + # Spaces become underscores per RFC 2047 §4.2(2). + # All other non-atext characters are QP-encoded. + defp encode_encoded_word_text(chunk) do + chunk + |> String.replace(" ", "_") + |> String.replace(~r/[^a-zA-Z0-9!#$%&'*+\-\/=?^_`{|}~]/, fn <> -> + "=" <> Base.encode16(<>) + end) + end + @doc """ Builds a RFC2822 timestamp from an Erlang timestamp diff --git a/test/mail/message_test.exs b/test/mail/message_test.exs index 3cf9344b..16a90d7c 100644 --- a/test/mail/message_test.exs +++ b/test/mail/message_test.exs @@ -219,7 +219,7 @@ defmodule Mail.MessageTest do |> Mail.put_subject(subject) |> Mail.render() - encoded_subject = "=?UTF-8?Q?" <> Mail.Encoders.QuotedPrintable.encode(subject) <> "?=" + encoded_subject = "=?UTF-8?Q?" <> encode_rfc2047(subject) <> "?=" assert String.contains?(txt, encoded_subject) assert %Mail.Message{headers: %{"subject" => ^subject}} = Mail.Parsers.RFC2822.parse(txt) @@ -244,14 +244,46 @@ defmodule Mail.MessageTest do |> Mail.put_to(to) |> Mail.render() - encoded_from = - ~s(From: =?UTF-8?Q?"#{Mail.Encoders.QuotedPrintable.encode(elem(from, 0))}"?= <#{elem(from, 1)}>) - - encoded_to = - ~s(To: =?UTF-8?Q?"#{Mail.Encoders.QuotedPrintable.encode(elem(to, 0))}"?= <#{elem(to, 1)}>) + encoded_from = "From: =?UTF-8?Q?#{encode_rfc2047(elem(from, 0))}?= <#{elem(from, 1)}>" + encoded_to = "To: =?UTF-8?Q?#{encode_rfc2047(elem(to, 0))}?= <#{elem(to, 1)}>" assert txt =~ encoded_from assert txt =~ encoded_to + + parsed = Mail.Parsers.RFC2822.parse(txt) + assert {elem(from, 0), elem(from, 1)} == parsed.headers["from"] + assert [{elem(to, 0), elem(to, 1)}] == parsed.headers["to"] + end + + test "UTF-8 in addresses round-trips with spaces" do + from = {"Mäx Müstermann", "max@example.com"} + + txt = + Mail.build() + |> Mail.put_from(from) + |> Mail.render() + + refute txt =~ ~r/=\?UTF-8\?Q\?[^?]* [^?]*\?=/ + parsed = Mail.Parsers.RFC2822.parse(txt) + assert from == parsed.headers["from"] + end + + test "UTF-8 in addresses with comma in name" do + from = {"Max, Müstermänn", "max@example.com"} + to = {"Other User", "other@example.com"} + + txt = + Mail.build() + |> Mail.put_from(from) + |> Mail.put_to(to) + |> Mail.render() + + # Comma must be encoded as =2C, not literal, to avoid being parsed as recipient separator + refute txt =~ ~r/=\?UTF-8\?Q\?[^?]*,[^?]*\?=/ + assert txt =~ "=2C" + + parsed = Mail.Parsers.RFC2822.parse(txt) + assert from == parsed.headers["from"] end test "UTF-8 in other header" do @@ -263,7 +295,7 @@ defmodule Mail.MessageTest do |> Mail.render() encoded_header_value = - "=?UTF-8?Q?" <> Mail.Encoders.QuotedPrintable.encode("READMEüä.md") <> "?=" + "=?UTF-8?Q?" <> encode_rfc2047("READMEüä.md") <> "?=" assert String.contains?(message, encoded_header_value) @@ -282,9 +314,18 @@ defmodule Mail.MessageTest do |> Mail.render() encoded_subject = - "=?UTF-8?Q?=C3=BCber alles=0Anew =3F=3D line some =D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C long line?=" + "=?UTF-8?Q?=C3=BCber_alles=0Anew_=3F=3D_line_some_=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD?==?UTF-8?Q?=D1=8C-=D0=BE=D1=87=D0=B5=D0=BD=D1=8C_long_line?=" assert String.contains?(txt, encoded_subject) assert %Mail.Message{headers: %{"subject" => ^subject}} = Mail.Parsers.RFC2822.parse(txt) end + + defp encode_rfc2047(string) do + string + |> Mail.Encoders.QuotedPrintable.encode() + |> String.replace(" ", "_") + |> String.replace(~r/[^a-zA-Z0-9!#$%&'*+\-\/=?^_`{|}~]/, fn <> -> + "=" <> Base.encode16(<>) + end) + end end diff --git a/test/mail/renderers/rfc_2822_test.exs b/test/mail/renderers/rfc_2822_test.exs index cbacf73a..50a5d818 100644 --- a/test/mail/renderers/rfc_2822_test.exs +++ b/test/mail/renderers/rfc_2822_test.exs @@ -52,19 +52,19 @@ defmodule Mail.Renderers.RFC2822Test do assert Mail.Renderers.RFC2822.render_header("Subject", [ "Hello World 😀" - ]) == "Subject: =?UTF-8?Q?Hello World =F0=9F=98=80?=" + ]) == "Subject: =?UTF-8?Q?Hello_World_=F0=9F=98=80?=" assert Mail.Renderers.RFC2822.render_header("Subject", [ "Café résumé" - ]) == "Subject: =?UTF-8?Q?Caf=C3=A9 r=C3=A9sum=C3=A9?=" + ]) == "Subject: =?UTF-8?Q?Caf=C3=A9_r=C3=A9sum=C3=A9?=" assert Mail.Renderers.RFC2822.render_header("Subject", [ "Hello 世界 World" - ]) == "Subject: =?UTF-8?Q?Hello =E4=B8=96=E7=95=8C World?=" + ]) == "Subject: =?UTF-8?Q?Hello_=E4=B8=96=E7=95=8C_World?=" assert Mail.Renderers.RFC2822.render_header("Subject", [ "normal subject\r\nReply-To: cleverhacker@example.com" - ]) == "Subject: =?UTF-8?Q?normal subject=0D=0AReply-To: cleverhacker@example.com?=" + ]) == "Subject: =?UTF-8?Q?normal_subject=0D=0AReply-To=3A_cleverhacker=40example=2Ecom?=" assert Mail.Renderers.RFC2822.render_header("Subject", [ "tabs\t\t and spaces" @@ -72,7 +72,7 @@ defmodule Mail.Renderers.RFC2822Test do assert Mail.Renderers.RFC2822.render_header("Subject", [ "delete \x7f vertical tab \x0b" - ]) == "Subject: =?UTF-8?Q?delete =7F vertical tab =0B?=" + ]) == "Subject: =?UTF-8?Q?delete_=7F_vertical_tab_=0B?=" end test "address headers renders list of recipients" do