Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/inline attachments #137

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
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
34 changes: 25 additions & 9 deletions lib/mail.ex
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,30 @@ defmodule Mail do
do: Mail.Message.put_attachment(message, {filename, data}, opts)

@doc """
Determines the message has any attachment parts
Determines the message has any non-inline attachment parts

Returns a `Boolean`
"""
def has_attachments?(%Mail.Message{} = message) do
walk_parts([message], {:cont, false}, fn message, _acc ->
case Mail.Message.is_attachment?(message) do
true -> {:halt, true}
false -> {:cont, false}
end
end)
|> elem(1)
has_message?(message, &Mail.Message.is_attachment?/1)
end

@doc """
Determines the message has any inline attachment parts

Returns a `Boolean`
"""
def has_inline_attachments?(%Mail.Message{} = message) do
has_message?(message, &Mail.Message.is_inline_attachment?/1)
end

@doc """
Determines the message has any inline attachment parts

Returns a `Boolean`
"""
def has_any_attachments?(%Mail.Message{} = message) do
has_message?(message, &Mail.Message.is_any_attachment?/1)
end

@doc """
Expand All @@ -211,8 +223,12 @@ defmodule Mail do
Returns a `Boolean`
"""
def has_text_parts?(%Mail.Message{} = message) do
has_message?(message, &Mail.Message.is_text_part?/1)
end

defp has_message?(%Mail.Message{} = message, condition) do
walk_parts([message], {:cont, false}, fn message, _acc ->
case Mail.Message.is_text_part?(message) do
case condition.(message) do
true -> {:halt, true}
false -> {:cont, false}
end
Expand Down
50 changes: 45 additions & 5 deletions lib/mail/message.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@ defmodule Mail.Message do
iex> Mail.Message.put_part(%Mail.Message{}, %Mail.Message{})
%Mail.Message{parts: [%Mail.Message{}]}
"""
def put_part(message, %Mail.Message{} = part) do
put_in(message.parts, message.parts ++ [part])
end
def put_part(message, %Mail.Message{} = part),
do: put_in(message.parts, message.parts ++ [part])

@doc """
Add an arbitrary amount of parts

Mail.Message.put_parts(%Mail.Message{}, [%Mail.Message{}, %Mail.Message{}])
"""
def put_parts(message, parts) when is_list(parts),
do: put_in(message.parts, message.parts ++ parts)

@doc """
Delete a matching part
Expand All @@ -27,6 +34,12 @@ defmodule Mail.Message do
def delete_part(message, part),
do: put_in(message.parts, List.delete(message.parts, part))

@doc """
Will remove all parts from message.
"""
def delete_all_parts(message),
do: put_in(message.parts, [])

@doc """
Will match on a full or partial content type

Expand Down Expand Up @@ -373,15 +386,15 @@ defmodule Mail.Message do
end

@doc """
Is the part an attachment or not
Is the part a non-inline attachment or not

Returns `Boolean`
"""
def is_attachment?(message),
do: Enum.member?(List.wrap(get_header(message, :content_disposition)), "attachment")

@doc """
Determines the message has any attachment parts
Determines the message has any non-inline attachment parts

Returns a `Boolean`
"""
Expand All @@ -391,6 +404,33 @@ defmodule Mail.Message do
def has_attachment?(message),
do: has_attachment?(message.parts)

@doc """
Is the part an inline attachment or not

Returns `Boolean`
"""
def is_inline_attachment?(message),
do: Enum.member?(List.wrap(get_header(message, :content_disposition)), "inline")

@doc """
Determines the message has any inline attachment parts

Returns a `Boolean`
"""
def has_inline_attachment?(parts) when is_list(parts),
do: has_part?(parts, &is_inline_attachment?/1)

def has_inline_attachment?(message),
do: has_inline_attachment?(message.parts)

@doc """
Is the part any kind of attachment or not

Returns `Boolean`
"""
def is_any_attachment?(message),
do: is_inline_attachment?(message) || is_attachment?(message)

@doc """
Is the message text based or not

Expand Down
75 changes: 64 additions & 11 deletions lib/mail/renderers/rfc_2822.ex
Original file line number Diff line number Diff line change
Expand Up @@ -251,30 +251,83 @@ defmodule Mail.Renderers.RFC2822 do
|> String.pad_leading(2, "0")
end

defp reorganize(%Mail.Message{multipart: true} = message) do
defp split_attachment_parts(message) do
Enum.reduce(message.parts, [[], [], []], fn part, [texts, mixed, inlines] ->
cond do
match_content_type?(part, ~r/text\/(plain|html)/) ->
[[part | texts], mixed, inlines]
Mail.Message.is_inline_attachment?(part) ->
[texts, mixed, [part | inlines]]
true -> # a mixed part - most likely an attachment
[texts, [part | mixed], inlines]
end
end)
|> Enum.map(&Enum.reverse/1) # retain ordering
end

@doc """
Will organize message parts to conform to expectations on MIME-part order,
specifically in the following format:

start multipart/mixed
start multipart/related
start multipart/alternative
<text and html parts>
end multipart/alternative
<inline parts>
end multipart/related
<attachment parts>
end multipart/mixed

Such that:

- text and html parts will be grouped in a `multipart/alternative`;
- inline attachments will be postpended and grouped with text and html parts
in a `multipart/related` (RFC 2387); and
- regular attachments will be postpended and grouped with all content in a
`multipart/mixed`
"""
def reorganize(%Mail.Message{multipart: true} = message) do
content_type = Mail.Message.get_content_type(message)

if Mail.Message.has_attachment?(message) do
text_parts =
Enum.filter(message.parts, &match_content_type?(&1, ~r/text\/(plain|html)/))
|> Enum.sort(&(&1 > &2))
[text_parts, mixed, inlines] = split_attachment_parts(message)
has_inline = Enum.any?(inlines)
has_mixed_parts = Enum.any?(mixed)
has_text_parts = Enum.any?(text_parts)

if has_inline || has_mixed_parts do
# If any attaching, change content type to mixed
content_type = List.replace_at(content_type, 0, "multipart/mixed")
message = Mail.Message.put_content_type(message, content_type)

if Enum.any?(text_parts) do
message = Enum.reduce(text_parts, message, &Mail.Message.delete_part(&2, &1))

mixed_part =
if has_text_parts do
# If any text with attachments, wrap in new part
body_part =
Mail.build_multipart()
|> Mail.Message.put_content_type("multipart/alternative")
|> Mail.Message.put_parts(text_parts)

# If any inline attachments, wrap together with text
# in a "multipart/related" part
body_part = if has_inline do
Mail.build_multipart()
|> Mail.Message.put_content_type("multipart/related")
|> Mail.Message.put_part(body_part)
|> Mail.Message.put_parts(inlines)
else
body_part
end

mixed_part = Enum.reduce(text_parts, mixed_part, &Mail.Message.put_part(&2, &1))
put_in(message.parts, List.insert_at(message.parts, 0, mixed_part))
message
|> Mail.Message.delete_all_parts()
|> Mail.Message.put_part(body_part)
|> Mail.Message.put_parts(mixed)
else
# If not text sections, leave all parts as is
message
end
else
# If only text, change content type to alternative
content_type = List.replace_at(content_type, 0, "multipart/alternative")
Mail.Message.put_content_type(message, content_type)
end
Expand Down
139 changes: 139 additions & 0 deletions test/mail/renderers/rfc_2822_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -339,4 +339,143 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
end
end

test "will have correct part order for regular message" do
message =
Mail.build_multipart()
|> Mail.put_to("[email protected]")
|> Mail.put_from({"User2", "[email protected]"})
|> Mail.put_subject("Test email")
|> Mail.put_text("Some text")
|> Mail.put_html("<h1>Some HTML</h1>")
|> Mail.Renderers.RFC2822.reorganize()

assert %Mail.Message{
headers: %{"content-type" => ["multipart/alternative"]},
parts: [
%Mail.Message{
headers: %{"content-type" => ["text/plain", {"charset", "UTF-8"}]},
body: "Some text",
parts: [],
multipart: false
},
%Mail.Message{
headers: %{"content-type" => ["text/html", {"charset", "UTF-8"}]},
body: "<h1>Some HTML</h1>",
parts: [],
multipart: false
}
]
} = message
end

test "will have correct part order with only a regular attachment" do
message =
Mail.build_multipart()
|> Mail.put_to("[email protected]")
|> Mail.put_from({"User2", "[email protected]"})
|> Mail.put_subject("Test email")
|> Mail.put_attachment({"tiny_jpeg.jpg", @tiny_jpeg_binary})
|> Mail.put_text("Some text")
|> Mail.put_html("<h1>Some HTML</h1>")
|> Mail.Renderers.RFC2822.reorganize()

assert %Mail.Message{
headers: %{"content-type" => ["multipart/mixed"]},
parts: [
%Mail.Message{
headers: %{"content-type" => ["multipart/alternative"]},
parts: [
%Mail.Message{
headers: %{"content-type" => ["text/plain", {"charset", "UTF-8"}]},
body: "Some text",
parts: [],
multipart: false
},
%Mail.Message{
headers: %{"content-type" => ["text/html", {"charset", "UTF-8"}]},
body: "<h1>Some HTML</h1>",
parts: [],
multipart: false
}
]
},
%Mail.Message{
headers: %{
"content-transfer-encoding" => :base64,
"content-type" => ["image/jpeg"]
},
parts: [],
multipart: false
}
]
} = message
end

test "will have correct part order with inline attachment" do
message =
Mail.build_multipart()
|> Mail.put_to("[email protected]")
|> Mail.put_from({"User2", "[email protected]"})
|> Mail.put_subject("Test email")
|> Mail.put_attachment({"tiny_jpeg.jpg", @tiny_jpeg_binary})
|> Mail.put_attachment({"inline_jpeg.jpg", @tiny_jpeg_binary},
headers: %{
content_id: "c_id",
content_type: "image/jpeg",
x_attachment_id: "a_id",
content_disposition: ["inline", filename: "filename"]
}
)
|> Mail.put_text("Some text")
|> Mail.put_html("<h1>Some HTML</h1>")
|> Mail.Renderers.RFC2822.reorganize()

assert %Mail.Message{
headers: %{"content-type" => ["multipart/mixed"]},
parts: [
%Mail.Message{
headers: %{"content-type" => ["multipart/related"]},
parts: [
%Mail.Message{
headers: %{"content-type" => ["multipart/alternative"]},
parts: [
%Mail.Message{
headers: %{"content-type" => ["text/plain", {"charset", "UTF-8"}]},
body: "Some text",
parts: [],
multipart: false
},
%Mail.Message{
headers: %{"content-type" => ["text/html", {"charset", "UTF-8"}]},
body: "<h1>Some HTML</h1>",
parts: [],
multipart: false
}
]
},
%Mail.Message{
headers: %{
"content-transfer-encoding" => :base64,
"content-type" => "image/jpeg",
"content-id" => "c_id",
"content-disposition" => ["inline", {:filename, "filename"}],
"x-attachment-id" => "a_id",
},
parts: [],
multipart: false
}
]
},
%Mail.Message{
headers: %{
"content-transfer-encoding" => :base64,
"content-type" => ["image/jpeg"]
},
parts: [],
multipart: false
}
]
} = message
end
end