Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/mail/parsers/rfc_2822.ex
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parser currently only supported Parameter Value Continuations which has the format key*index="value"

        Content-Type: message/external-body; access-type=URL;
         URL*0="ftp://";
         URL*1="cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar"

The non-continuation version is simply: key*="value"

Original file line number Diff line number Diff line change
Expand Up @@ -947,11 +947,11 @@ defmodule Mail.Parsers.RFC2822 do
|> Enum.map(fn
# Find parameters that are split into multiple parts
{key, value} when is_binary(value) ->
case String.split(key, "*", parts: 2) do
case String.split(key, "*", parts: 2, trim: true) do
[key, part] ->
{{key, part}, value}

_ ->
[key] ->
{key, value}
end

Expand Down
61 changes: 47 additions & 14 deletions lib/mail/renderers/rfc_2822.ex
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ defmodule Mail.Renderers.RFC2822 do
render_header_value(key, value)
end

@rfc2231_headers ["Content-Disposition", "Content-Type"]
defp render_header_value(key, value) when key in @rfc2231_headers and is_binary(value) do
value
end

defp render_header_value(key, [value | subtypes]) when key in @rfc2231_headers do
Enum.join([value | render_subtypes(subtypes, :rfc_2231)], "; ")
end

defp render_header_value(_key, [value | subtypes]),
do:
Enum.join([encode_header_value(value, :quoted_printable) | render_subtypes(subtypes)], "; ")
Expand All @@ -143,27 +152,46 @@ defmodule Mail.Renderers.RFC2822 do

defp render_address(email), do: validate_address(email)

defp render_subtypes([]), do: []
defp render_subtypes(subtypes, encoding \\ :quoted_printable)

defp render_subtypes([], _encoding), do: []

defp render_subtypes([{key, value} | subtypes]) when is_atom(key),
do: render_subtypes([{Atom.to_string(key), value} | subtypes])
defp render_subtypes([{key, value} | subtypes], encoding) when is_atom(key),
do: render_subtypes([{Atom.to_string(key), value} | subtypes], encoding)

defp render_subtypes([{"boundary", value} | subtypes]) do
[~s(boundary="#{value}") | render_subtypes(subtypes)]
defp render_subtypes([{"boundary", value} | subtypes], encoding) do
[~s(boundary="#{value}") | render_subtypes(subtypes, encoding)]
end

defp render_subtypes([{key, value} | subtypes]) do
# RFC 2231 parameter encoding for Content-Type and Content-Disposition
defp render_subtypes([{key, value} | subtypes], :rfc_2231) do
key = String.replace(key, "_", "-")
value = encode_header_value(value, :quoted_printable)

value =
if value =~ ~r/[\s()<>@,;:\\<\/\[\]?=]/ do
"\"#{value}\""
else
value
end
if contains_non_ascii?(value) do
value = encode_header_value(value, :rfc_2231)
["#{key}*=UTF-8''#{value}" | render_subtypes(subtypes, :rfc_2231)]
else
value = maybe_wrap_in_quotes(value)
["#{key}=#{value}" | render_subtypes(subtypes, :rfc_2231)]
end
end
Comment on lines +167 to +177
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new handler is here.
The logic of the other handler is untouched


["#{key}=#{value}" | render_subtypes(subtypes)]
defp render_subtypes([{key, value} | subtypes], :quoted_printable) do
key = String.replace(key, "_", "-")
value = value |> encode_header_value(:quoted_printable) |> maybe_wrap_in_quotes()
["#{key}=#{value}" | render_subtypes(subtypes, :quoted_printable)]
end

defp contains_non_ascii?(value) do
String.match?(value, ~r/[\x80-\xFF]/)
end

defp maybe_wrap_in_quotes(value) do
if value =~ ~r/[\s()<>@,;:\\<\/\[\]?=]/ do
"\"#{value}\""
else
value
end
end

@doc """
Expand Down Expand Up @@ -198,6 +226,11 @@ defmodule Mail.Renderers.RFC2822 do
end
end

defp encode_header_value(header_value, :rfc_2231) do
# RFC 2231: parameter*=UTF-8''percent-encoded-value
URI.encode(header_value, &URI.char_unreserved?/1)
end

defp wrap_encoded_words(value) do
:binary.split(value, "=\r\n", [:global])
|> Enum.map(fn chunk -> <<"=?UTF-8?Q?", chunk::binary, "?=">> end)
Expand Down
12 changes: 8 additions & 4 deletions test/mail/message_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,17 @@ defmodule Mail.MessageTest do
|> Mail.put_attachment({file_name, "data"}, headers: [content_id: "attachment-id"])
|> Mail.render()

encoded_header_value =
"=?UTF-8?Q?" <> Mail.Encoders.QuotedPrintable.encode("READMEüä.md") <> "?="
refute String.contains?(message, "=?UTF-8?Q?")

assert String.contains?(message, encoded_header_value)
assert String.contains?(message, "filename*=UTF-8''README%C3%BC%C3%A4.md")

assert %Mail.Message{
headers: %{"content-disposition" => ["attachment", {"filename", ^file_name}]}
headers: %{
"content-disposition" => [
"attachment",
{"filename", "UTF-8''README%C3%BC%C3%A4.md"}
]
}
} = Mail.Parsers.RFC2822.parse(message)
end

Expand Down
45 changes: 45 additions & 0 deletions test/mail/renderers/rfc_2822_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,51 @@ defmodule Mail.Renderers.RFC2822Test do
end
end

describe "RFC 2047 encoding restrictions" do
test "Subject header uses RFC 2047 encoding for non-ASCII characters" do
header = Mail.Renderers.RFC2822.render_header("subject", "café")
assert header == "Subject: =?UTF-8?Q?caf=C3=A9?="

header = Mail.Renderers.RFC2822.render_header("subject", "Test 日本語 Subject")
assert header =~ "Subject: =?UTF-8?Q?"
assert header =~ "?="
end

test "From header uses RFC 2047 encoding for non-ASCII names" do
header = Mail.Renderers.RFC2822.render_header("from", {"José García", "jose@example.com"})
assert header == ~s(From: =?UTF-8?Q?"Jos=C3=A9 Garc=C3=ADa"?= <jose@example.com>)

header = Mail.Renderers.RFC2822.render_header("from", {"山田太郎", "yamada@example.com"})
assert header =~ "From: =?UTF-8?Q?"
assert header =~ "?= <yamada@example.com>"
end

test "Content-Disposition non-ASCII filename parameter uses RFC 2231 encoding" do
# RFC 2047 explicitly forbids encoded-words in Content-Disposition parameters
header =
Mail.Renderers.RFC2822.render_header(
"Content-Disposition",
["attachment", filename: "café.pdf"]
)

# Should NOT contain RFC 2047 encoded-word markers
# Should use RFC 2231 encoding for non-ASCII characters
assert header == "Content-Disposition: attachment; filename*=UTF-8''caf%C3%A9.pdf"
end

test "Content-Disposition ASCII filename parameter does NOT use RFC 2047 encoding" do
# RFC 2047 explicitly forbids encoded-words in Content-Disposition parameters
header =
Mail.Renderers.RFC2822.render_header(
"Content-Disposition",
["attachment", filename: "name.pdf"]
)

# Should NOT contain RFC 2047 encoded-word markers
assert header == "Content-Disposition: attachment; filename=name.pdf"
end
end

describe "multipart configuration" do
test "multipart/alternative with text/plain and text/html" do
message =
Expand Down