Skip to content

Commit f738af7

Browse files
Better support for inline attachments. Fixes #137
Co-authored-by: Jarrod Moldrich <[email protected]>
1 parent a369dc3 commit f738af7

File tree

7 files changed

+616
-111
lines changed

7 files changed

+616
-111
lines changed

CHANGELOG.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
# Changelog
22

3+
## 0.4.5
4+
5+
* Update RFC2822 Renderer to correctly render multipart combinations of `multipart/alternative`, `multipart/related`, and `multipart/mixed` https://github.com/DockYard/elixir-mail/pull/137
6+
* Update `Mail.get_attachments/2` to handle inline attachments
7+
* Update `Mail.Message.is_attachment?/2` to handle inline attachments
8+
39
## 0.4.4 2025-03-24
410

5-
* Quote address name when needed #190
6-
* Encode address name when needed #189
7-
* Fix infinite loop with invalid parameters #187
8-
* Fix parsing of multiple recipients #
9-
* Fix get_html/1 to correctly handle multiple parameters in the content-type header #186
11+
* Quote address name when needed https://github.com/DockYard/elixir-mail/pull/190
12+
* Encode address name when needed https://github.com/DockYard/elixir-mail/pull/189
13+
* Fix infinite loop with invalid parameters https://github.com/DockYard/elixir-mail/pull/187
14+
* Fix parsing of multiple recipients
15+
* Fix get_html/1 to correctly handle multiple parameters in the content-type header https://github.com/DockYard/elixir-mail/pull/186
1016

1117
## 0.4.3 2024-11-15
1218

lib/mail.ex

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,11 @@ defmodule Mail do
5454
def put_text(message, body, opts \\ [])
5555

5656
def put_text(%Mail.Message{multipart: true} = message, body, opts) do
57-
message =
58-
case Enum.find(message.parts, &Mail.Message.match_body_text/1) do
59-
%Mail.Message{} = part -> Mail.Message.delete_part(message, part)
60-
_ -> message
61-
end
62-
63-
Mail.Message.put_part(message, Mail.Message.build_text(body, opts))
57+
Mail.Message.replace_part(
58+
message,
59+
&Mail.Message.match_body_text/1,
60+
Mail.Message.build_text(body, opts)
61+
)
6462
end
6563

6664
def put_text(%Mail.Message{} = message, body, opts) do
@@ -115,13 +113,11 @@ defmodule Mail do
115113
def put_html(message, body, opts \\ [])
116114

117115
def put_html(%Mail.Message{multipart: true} = message, body, opts) do
118-
message =
119-
case Enum.find(message.parts, &Mail.Message.match_content_type?(&1, "text/html")) do
120-
%Mail.Message{} = part -> Mail.Message.delete_part(message, part)
121-
_ -> message
122-
end
123-
124-
Mail.Message.put_part(message, Mail.Message.build_html(body, opts))
116+
Mail.Message.replace_part(
117+
message,
118+
&Mail.Message.match_content_type?(&1, "text/html"),
119+
Mail.Message.build_html(body, opts)
120+
)
125121
end
126122

127123
def put_html(%Mail.Message{} = message, body, opts) do
@@ -223,24 +219,30 @@ defmodule Mail do
223219
@doc """
224220
Walks the message parts and collects all attachments
225221
222+
Types:
223+
- `:all` - all attachments
224+
- `:attachment` - only attachments (default)
225+
- `:inline` - only inline attachments
226+
226227
Each member in the list is `{filename, content}`
227228
"""
228-
def get_attachments(%Mail.Message{} = message) do
229+
@spec get_attachments(Mail.Message.t(), :all | :attachment | :inline) :: [
230+
{String.t(), binary()}
231+
]
232+
def get_attachments(%Mail.Message{} = message, type \\ :attachment) do
229233
walk_parts([message], {:cont, []}, fn message, acc ->
230-
case Mail.Message.is_attachment?(message) do
231-
true ->
232-
filename =
233-
case List.wrap(Mail.Message.get_header(message, :content_disposition)) do
234-
["attachment" | properties] ->
235-
Enum.find_value(properties, "Unknown", fn {key, value} ->
236-
key == "filename" && value
237-
end)
238-
end
239-
240-
{:cont, List.insert_at(acc, -1, {filename, message.body})}
241-
242-
false ->
243-
{:cont, acc}
234+
if Mail.Message.is_attachment?(message, type) do
235+
filename =
236+
case List.wrap(Mail.Message.get_header(message, :content_disposition)) do
237+
[_ | properties] ->
238+
Enum.find_value(properties, "Unknown", fn {key, value} ->
239+
key == "filename" && value
240+
end)
241+
end
242+
243+
{:cont, List.insert_at(acc, -1, {filename, message.body})}
244+
else
245+
{:cont, acc}
244246
end
245247
end)
246248
|> elem(1)

lib/mail/message.ex

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ defmodule Mail.Message do
1818
put_in(message.parts, message.parts ++ [part])
1919
end
2020

21+
def put_parts(message, parts) when is_list(parts) do
22+
put_in(message.parts, message.parts ++ parts)
23+
end
24+
25+
def replace_part(message, match_fun, %Mail.Message{} = part) when is_function(match_fun, 1) do
26+
parts =
27+
message.parts
28+
|> Enum.reject(match_fun)
29+
|> Kernel.++([part])
30+
31+
%{message | parts: parts}
32+
end
33+
2134
@doc """
2235
Delete a matching part
2336
@@ -375,21 +388,40 @@ defmodule Mail.Message do
375388
@doc """
376389
Is the part an attachment or not
377390
391+
Types:
392+
- `:all` - all attachments
393+
- `:attachment` - only attachments (default)
394+
- `:inline` - only inline attachments
395+
378396
Returns `Boolean`
379397
"""
380-
def is_attachment?(message),
381-
do: Enum.member?(List.wrap(get_header(message, :content_disposition)), "attachment")
398+
@spec is_attachment?(Mail.Message.t(), :all | :attachment | :inline) :: boolean()
399+
def is_attachment?(message, type \\ :attachment) do
400+
types =
401+
case type do
402+
:all -> ["attachment", "inline"]
403+
:attachment -> ["attachment"]
404+
:inline -> ["inline"]
405+
end
406+
407+
case List.wrap(get_header(message, :content_disposition)) do
408+
[disposition | _] -> disposition in types
409+
_ -> false
410+
end
411+
end
382412

383413
@doc """
384414
Determines the message has any attachment parts
385415
386416
Returns a `Boolean`
387417
"""
388-
def has_attachment?(parts) when is_list(parts),
389-
do: has_part?(parts, &is_attachment?/1)
418+
def has_attachment?(parts, type \\ :attachment)
419+
420+
def has_attachment?(parts, type) when is_list(parts),
421+
do: has_part?(parts, &is_attachment?(&1, type))
390422

391-
def has_attachment?(message),
392-
do: has_attachment?(message.parts)
423+
def has_attachment?(message, type),
424+
do: has_attachment?(message.parts, type)
393425

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

lib/mail/renderers/rfc_2822.ex

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,9 @@ defmodule Mail.Renderers.RFC2822 do
138138
end
139139
end
140140

141-
defp render_address({name, email}), do: "#{encode_header_value(~s("#{name}"), :quoted_printable)} <#{validate_address(email)}>"
141+
defp render_address({name, email}),
142+
do: "#{encode_header_value(~s("#{name}"), :quoted_printable)} <#{validate_address(email)}>"
143+
142144
defp render_address(email), do: validate_address(email)
143145

144146
defp render_subtypes([]), do: []
@@ -261,31 +263,45 @@ defmodule Mail.Renderers.RFC2822 do
261263
end
262264

263265
defp reorganize(%Mail.Message{multipart: true} = message) do
264-
content_type = Mail.Message.get_content_type(message)
265-
266-
if Mail.Message.has_attachment?(message) do
267-
text_parts =
268-
Enum.filter(message.parts, &match_content_type?(&1, ~r/text\/(plain|html)/))
269-
|> Enum.sort(&(&1 > &2))
270-
271-
content_type = List.replace_at(content_type, 0, "multipart/mixed")
272-
message = Mail.Message.put_content_type(message, content_type)
266+
{text_parts, attachments} =
267+
message.parts
268+
|> Enum.split_with(&match_content_type?(&1, ~r/text\/(plain|html)/))
273269

274-
if Enum.any?(text_parts) do
275-
message = Enum.reduce(text_parts, message, &Mail.Message.delete_part(&2, &1))
270+
{inline_attachments, other_attachments} =
271+
Enum.split_with(attachments, &Mail.Message.is_attachment?(&1, :inline))
276272

277-
mixed_part =
273+
if Enum.empty?(text_parts) do
274+
Mail.Message.put_content_type(message, "multipart/mixed")
275+
else
276+
alternative =
277+
case text_parts do
278+
[part] ->
279+
part
280+
281+
text_parts ->
282+
Mail.build_multipart()
283+
|> Mail.Message.put_content_type("multipart/alternative")
284+
|> Mail.Message.put_parts(text_parts)
285+
end
286+
287+
related =
288+
if Enum.empty?(inline_attachments) do
289+
alternative
290+
else
278291
Mail.build_multipart()
279-
|> Mail.Message.put_content_type("multipart/alternative")
292+
|> Mail.Message.put_content_type("multipart/related")
293+
|> Mail.Message.put_part(alternative)
294+
|> Mail.Message.put_parts(inline_attachments)
295+
end
280296

281-
mixed_part = Enum.reduce(text_parts, mixed_part, &Mail.Message.put_part(&2, &1))
282-
put_in(message.parts, List.insert_at(message.parts, 0, mixed_part))
297+
if Enum.empty?(other_attachments) do
298+
related
283299
else
284-
message
300+
Mail.build_multipart()
301+
|> Mail.Message.put_content_type("multipart/mixed")
302+
|> Mail.Message.put_part(related)
303+
|> Mail.Message.put_parts(other_attachments)
285304
end
286-
else
287-
content_type = List.replace_at(content_type, 0, "multipart/alternative")
288-
Mail.Message.put_content_type(message, content_type)
289305
end
290306
end
291307

test/mail/message_test.exs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,11 @@ defmodule Mail.MessageTest do
244244
|> Mail.put_to(to)
245245
|> Mail.render()
246246

247-
encoded_from = ~s(From: =?UTF-8?Q?"#{Mail.Encoders.QuotedPrintable.encode(elem(from, 0))}"?= <#{elem(from, 1)}>)
248-
encoded_to = ~s(To: =?UTF-8?Q?"#{Mail.Encoders.QuotedPrintable.encode(elem(to, 0))}"?= <#{elem(to, 1)}>)
247+
encoded_from =
248+
~s(From: =?UTF-8?Q?"#{Mail.Encoders.QuotedPrintable.encode(elem(from, 0))}"?= <#{elem(from, 1)}>)
249+
250+
encoded_to =
251+
~s(To: =?UTF-8?Q?"#{Mail.Encoders.QuotedPrintable.encode(elem(to, 0))}"?= <#{elem(to, 1)}>)
249252

250253
assert txt =~ encoded_from
251254
assert txt =~ encoded_to

0 commit comments

Comments
 (0)