diff --git a/UPGRADE_0.6.md b/UPGRADE_0.6.md
new file mode 100644
index 00000000..fcb9a109
--- /dev/null
+++ b/UPGRADE_0.6.md
@@ -0,0 +1,95 @@
+# Upgrade guide: ordered, multi-valued headers
+
+This release changes how `Mail.Message` stores and exposes headers so repeated headers (e.g. `Received`) are preserved.
+
+## Summary of changes
+
+- **Headers are no longer stored in a map.**
+ - `Mail.Message.headers` is now a `%Mail.Headers{}` that stores headers as an **ordered list** of `{name, value}` pairs.
+ - Header names are stored **lowercase** and `_` is normalized to `-`.
+
+- **Reading all header instances**
+ - `Mail.Message.get_header/2` now **always returns a list** of all header values for that name (in stored order).
+
+- **Reading a singleton header**
+ - `Mail.Message.get_header!/2` returns the single header value or `nil`, and **raises** if there are multiple values.
+
+- **Writing headers**
+ - `Mail.Message.put_header/3` **replaces all instances** of that header name with the new value.
+ - `Mail.Message.prepend_header/3` **adds** a new header instance (duplicates allowed) at the beginning.
+
+- **`Mail.get_*` getters remain scalar**
+ - `Mail.get_subject/1`, `Mail.get_from/1`, `Mail.get_to/1`, etc. still return a scalar by selecting the **first** header instance.
+ - New strict variants like `Mail.get_subject!/1`, `Mail.get_from!/1`, etc. return the single value or `nil`, and **raise** on duplicates.
+
+## Common code changes
+
+### Direct map access on `headers`
+
+**Before**
+
+```elixir
+message.headers["subject"]
+Map.has_key?(message.headers, "bcc")
+```
+
+**After**
+
+```elixir
+# `headers[...]` still works (via Access):
+# - returns `nil` when missing
+# - returns the single value when exactly one header exists
+# - returns a list of values when multiple headers exist
+message.headers["subject"]
+
+# Prefer explicit helpers when you want a specific contract:
+Mail.Message.has_header?(message, "bcc")
+```
+
+### Getting all header instances
+
+**Before (single value or special-cased in a map)**
+
+```elixir
+received = message.headers["received"]
+```
+
+**After**
+
+```elixir
+received_values = Mail.Message.get_header(message, "received")
+```
+
+### Getting a singleton header safely
+
+**Before**
+
+```elixir
+content_type = message.headers["content-type"]
+```
+
+**After**
+
+```elixir
+content_type = Mail.Message.get_header!(message, "content-type")
+```
+
+### Pattern matches on headers
+
+**Before**
+
+```elixir
+%Mail.Message{headers: %{"content-type" => ["text/plain" | _]}} = part
+```
+
+**After**
+
+```elixir
+["text/plain" | _] = Mail.Message.get_header!(part, "content-type")
+```
+
+## Notes
+
+- If you were previously depending on implicit ordering from maps, you should now rely on the explicit **header insertion order**.
+- If you need to preserve duplicates when copying headers, avoid converting to a map (which collapses duplicates).
+
diff --git a/lib/mail.ex b/lib/mail.ex
index 86bb29e6..6c2a294e 100644
--- a/lib/mail.ex
+++ b/lib/mail.ex
@@ -72,8 +72,8 @@ defmodule Mail do
end
Mail.Message.put_body(message, body)
- |> Mail.Message.put_header(:content_transfer_encoding, :quoted_printable)
|> Mail.Message.put_content_type(content_type)
+ |> Mail.Message.put_header(:content_transfer_encoding, :quoted_printable)
end
@doc """
@@ -93,10 +93,12 @@ defmodule Mail do
end)
end
- def get_text(%Mail.Message{headers: %{"content-type" => ["text/plain" | _]}} = message),
- do: message
-
- def get_text(%Mail.Message{}), do: nil
+ def get_text(%Mail.Message{} = message) do
+ case Mail.Message.get_content_type(message) do
+ ["text/plain" | _] -> message
+ _ -> nil
+ end
+ end
@doc """
Add an HTML part to the message
@@ -131,8 +133,8 @@ defmodule Mail do
end
Mail.Message.put_body(message, body)
- |> Mail.Message.put_header(:content_transfer_encoding, :quoted_printable)
|> Mail.Message.put_content_type(content_type)
+ |> Mail.Message.put_header(:content_transfer_encoding, :quoted_printable)
end
@doc """
@@ -152,12 +154,12 @@ defmodule Mail do
end)
end
- def get_html(%Mail.Message{headers: %{"content-type" => "text/html"}} = message), do: message
-
- def get_html(%Mail.Message{headers: %{"content-type" => ["text/html" | _]}} = message),
- do: message
-
- def get_html(%Mail.Message{}), do: nil
+ def get_html(%Mail.Message{} = message) do
+ case Mail.Message.get_content_type(message) do
+ ["text/html" | _] -> message
+ _ -> nil
+ end
+ end
defp default_charset do
"UTF-8"
@@ -244,7 +246,7 @@ defmodule Mail do
defp get_attachment_filename(message) do
filename =
- case Mail.Message.get_header(message, :content_disposition) do
+ case Mail.Message.get_header!(message, :content_disposition) do
[_ | properties] ->
Enum.find_value(properties, fn {key, value} -> key == "filename" && value end)
@@ -255,7 +257,7 @@ defmodule Mail do
filename =
case filename do
nil ->
- case Mail.Message.get_header(message, :content_type) do
+ case Mail.Message.get_content_type(message) do
[_ | properties] ->
Enum.find_value(properties, fn {key, value} -> key == "name" && value end)
@@ -285,16 +287,25 @@ defmodule Mail do
## Examples
iex> Mail.put_subject(%Mail.Message{}, "Welcome to DockYard!")
- %Mail.Message{headers: %{"subject" => "Welcome to DockYard!"}}
+ iex> |> Mail.get_subject()
+ "Welcome to DockYard!"
"""
def put_subject(message, subject),
do: Mail.Message.put_header(message, "subject", subject)
@doc ~S"""
Retrieve the `subject` header
+ If multiple subject headers exist, returns the subject from the first header.
"""
def get_subject(message),
- do: Mail.Message.get_header(message, "subject")
+ do: Mail.Message.get_header(message, "subject") |> List.first()
+
+ @doc """
+ Retrieve the `subject` header or raise if multiple values exist
+ """
+ def get_subject!(message) do
+ Mail.Message.get_header!(message, "subject")
+ end
@doc """
Add new recipients to the `to` header
@@ -305,14 +316,17 @@ defmodule Mail do
## Examples
iex> Mail.put_to(%Mail.Message{}, "one@example.com")
- %Mail.Message{headers: %{"to" => ["one@example.com"]}}
+ iex> |> Mail.get_to()
+ ["one@example.com"]
iex> Mail.put_to(%Mail.Message{}, ["one@example.com", "two@example.com"])
- %Mail.Message{headers: %{"to" => ["one@example.com", "two@example.com"]}}
+ iex> |> Mail.get_to()
+ ["one@example.com", "two@example.com"]
iex> Mail.put_to(%Mail.Message{}, "one@example.com")
iex> |> Mail.put_to(["two@example.com", "three@example.com"])
- %Mail.Message{headers: %{"to" => ["one@example.com", "two@example.com", "three@example.com"]}}
+ iex> |> Mail.get_to()
+ ["one@example.com", "two@example.com", "three@example.com"]
The value of a recipient must conform to either a string value or a tuple with two elements,
otherwise an `ArgumentError` is raised.
@@ -334,9 +348,18 @@ defmodule Mail do
@doc ~S"""
Retrieves the list of recipients from the `to` header
+
+ If multiple to headers exist, returns the recipients from the first header.
"""
def get_to(message),
- do: Mail.Message.get_header(message, "to")
+ do: Mail.Message.get_header(message, "to") |> List.first()
+
+ @doc """
+ Retrieve the recipients from the `to` header or raise if multiple values exist
+ """
+ def get_to!(message) do
+ Mail.Message.get_header!(message, "to")
+ end
@doc """
Add new recipients to the `cc` header
@@ -347,14 +370,17 @@ defmodule Mail do
## Examples
iex> Mail.put_cc(%Mail.Message{}, "one@example.com")
- %Mail.Message{headers: %{"cc" => ["one@example.com"]}}
+ iex> |> Mail.get_cc()
+ ["one@example.com"]
iex> Mail.put_cc(%Mail.Message{}, ["one@example.com", "two@example.com"])
- %Mail.Message{headers: %{"cc" => ["one@example.com", "two@example.com"]}}
+ iex> |> Mail.get_cc()
+ ["one@example.com", "two@example.com"]
iex> Mail.put_cc(%Mail.Message{}, "one@example.com")
iex> |> Mail.put_cc(["two@example.com", "three@example.com"])
- %Mail.Message{headers: %{"cc" => ["one@example.com", "two@example.com", "three@example.com"]}}
+ iex> |> Mail.get_cc()
+ ["one@example.com", "two@example.com", "three@example.com"]
The value of a recipient must conform to either a string value or a tuple with two elements,
otherwise an `ArgumentError` is raised.
@@ -376,9 +402,17 @@ defmodule Mail do
@doc ~S"""
Retrieves the recipients from the `cc` header
+ If multiple cc headers exist, returns the recipients from the first header.
"""
def get_cc(message),
- do: Mail.Message.get_header(message, "cc")
+ do: Mail.Message.get_header(message, "cc") |> List.first()
+
+ @doc """
+ Retrieve the recipients from the `cc` header or raise if multiple values exist
+ """
+ def get_cc!(message) do
+ Mail.Message.get_header!(message, "cc")
+ end
@doc """
Add new recipients to the `bcc` header
@@ -389,14 +423,17 @@ defmodule Mail do
## Examples
iex> Mail.put_bcc(%Mail.Message{}, "one@example.com")
- %Mail.Message{headers: %{"bcc" => ["one@example.com"]}}
+ iex> |> Mail.get_bcc()
+ ["one@example.com"]
iex> Mail.put_bcc(%Mail.Message{}, ["one@example.com", "two@example.com"])
- %Mail.Message{headers: %{"bcc" => ["one@example.com", "two@example.com"]}}
+ iex> |> Mail.get_bcc()
+ ["one@example.com", "two@example.com"]
iex> Mail.put_bcc(%Mail.Message{}, "one@example.com")
iex> |> Mail.put_bcc(["two@example.com", "three@example.com"])
- %Mail.Message{headers: %{"bcc" => ["one@example.com", "two@example.com", "three@example.com"]}}
+ iex> |> Mail.get_bcc()
+ ["one@example.com", "two@example.com", "three@example.com"]
The value of a recipient must conform to either a string value or a tuple with two elements,
otherwise an `ArgumentError` is raised.
@@ -418,9 +455,17 @@ defmodule Mail do
@doc ~S"""
Retrieves the recipients from the `bcc` header
+ If multiple bcc headers exist, returns the recipients from the first header.
"""
def get_bcc(message),
- do: Mail.Message.get_header(message, "bcc")
+ do: Mail.Message.get_header(message, "bcc") |> List.first()
+
+ @doc """
+ Retrieve the recipients from the `bcc` header or raise if multiple values exist
+ """
+ def get_bcc!(message) do
+ Mail.Message.get_header!(message, "bcc")
+ end
@doc """
Add a new `from` header
@@ -428,7 +473,8 @@ defmodule Mail do
## Examples
iex> Mail.put_from(%Mail.Message{}, "user@example.com")
- %Mail.Message{headers: %{"from" => "user@example.com"}}
+ iex> |> Mail.get_from()
+ "user@example.com"
"""
def put_from(message, sender) do
validate_recipients([sender])
@@ -437,9 +483,17 @@ defmodule Mail do
@doc ~S"""
Retrieves the `from` header
+ If multiple from headers exist, returns the sender from the first header.
"""
def get_from(message),
- do: Mail.Message.get_header(message, "from")
+ do: Mail.Message.get_header(message, "from") |> List.first()
+
+ @doc """
+ Retrieve the sender from the `from` header or raise if multiple values exist
+ """
+ def get_from!(message) do
+ Mail.Message.get_header!(message, "from")
+ end
@doc """
Add a new `reply-to` header
@@ -447,16 +501,25 @@ defmodule Mail do
## Examples
iex> Mail.put_reply_to(%Mail.Message{}, "user@example.com")
- %Mail.Message{headers: %{"reply-to" => "user@example.com"}}
+ iex> |> Mail.get_reply_to()
+ "user@example.com"
"""
def put_reply_to(message, reply_address),
do: Mail.Message.put_header(message, "reply-to", reply_address)
@doc ~S"""
Retrieves the `reply-to` header
+ If multiple reply-to headers exist, returns the reply address from the first header.
"""
def get_reply_to(message),
- do: Mail.Message.get_header(message, "reply-to")
+ do: Mail.Message.get_header(message, "reply-to") |> List.first()
+
+ @doc """
+ Retrieve the reply address from the `reply-to` header or raise if multiple values exist
+ """
+ def get_reply_to!(message) do
+ Mail.Message.get_header!(message, "reply-to")
+ end
@doc """
Returns a unique list of all recipients
diff --git a/lib/mail/headers.ex b/lib/mail/headers.ex
new file mode 100644
index 00000000..e96a5e4f
--- /dev/null
+++ b/lib/mail/headers.ex
@@ -0,0 +1,172 @@
+defmodule Mail.Headers do
+ @moduledoc """
+ Ordered, multi-valued header storage.
+
+ Headers are stored as a list of `{name, value}` tuples in insertion order.
+ Header names are normalized to lowercase and `_` is converted to `-`.
+ """
+
+ @behaviour Access
+
+ @type header_name :: String.t()
+ @type header_value :: any()
+ @type item :: {header_name(), header_value()}
+
+ defstruct items: []
+
+ @type t :: %__MODULE__{items: [item()]}
+
+ @spec new() :: t()
+ def new, do: %__MODULE__{items: []}
+
+ @spec to_list(t()) :: [item()]
+ def to_list(%__MODULE__{items: items}), do: items
+
+ @spec normalize_name(atom() | String.t()) :: header_name()
+ def normalize_name(name) when is_atom(name), do: name |> Atom.to_string() |> normalize_name()
+
+ def normalize_name(name) when is_binary(name) do
+ name
+ |> String.downcase()
+ |> String.replace("_", "-")
+ end
+
+ @spec values(t(), atom() | String.t()) :: [header_value()]
+ def values(%__MODULE__{items: items}, name) do
+ normalized = normalize_name(name)
+
+ items
+ |> Enum.reduce([], fn
+ {^normalized, value}, acc -> [value | acc]
+ _other, acc -> acc
+ end)
+ |> Enum.reverse()
+ end
+
+ @spec has?(t(), atom() | String.t()) :: boolean()
+ def has?(%__MODULE__{} = headers, name) do
+ normalized = normalize_name(name)
+ Enum.any?(headers.items, fn {k, _v} -> k == normalized end)
+ end
+
+ @spec delete(t(), atom() | String.t()) :: t()
+ def delete(%__MODULE__{} = headers, name) do
+ normalized = normalize_name(name)
+ %{headers | items: Enum.reject(headers.items, fn {k, _v} -> k == normalized end)}
+ end
+
+ @spec put(t(), atom() | String.t(), header_value()) :: t()
+ def put(%__MODULE__{} = headers, name, value) do
+ normalized = normalize_name(name)
+
+ headers
+ |> delete(normalized)
+ |> append(normalized, value)
+ end
+
+ @spec append(t(), atom() | String.t(), header_value()) :: t()
+ def append(%__MODULE__{} = headers, name, value) do
+ normalized = normalize_name(name)
+ %{headers | items: headers.items ++ [{normalized, value}]}
+ end
+
+ @spec prepend(t(), atom() | String.t(), header_value()) :: t()
+ def prepend(%__MODULE__{} = headers, name, value) do
+ normalized = normalize_name(name)
+ %{headers | items: [{normalized, value} | headers.items]}
+ end
+
+ @doc """
+ Prepends every `{name, value}` from `source` onto `target`, preserving `source`'s
+ order as a block before `target`'s existing headers.
+
+ Duplicate names (including repeated keys in `source`) are kept; this does not
+ merge or replace by header name.
+ """
+ @spec prepend_headers(t(), t() | [item()]) :: t()
+ def prepend_headers(%__MODULE__{} = target, %__MODULE__{items: items}),
+ do: prepend_headers(target, items)
+
+ def prepend_headers(%__MODULE__{} = target, items) when is_list(items) do
+ Enum.reduce(Enum.reverse(items), target, fn {name, value}, acc ->
+ prepend(acc, name, value)
+ end)
+ end
+
+ @spec get_single!(t(), atom() | String.t()) :: header_value() | nil
+ def get_single!(%__MODULE__{} = headers, name) do
+ case values(headers, name) do
+ [] ->
+ nil
+
+ [value] ->
+ value
+
+ [_ | _] ->
+ raise ArgumentError, "multiple header values for #{inspect(normalize_name(name))}"
+ end
+ end
+
+ # Access behaviour:
+ # - returns `nil` when missing
+ # - returns the single value when exactly one header exists
+ # - returns a list of values when multiple headers exist
+ @impl Access
+ def fetch(%__MODULE__{} = headers, key) do
+ normalized = normalize_name(key)
+
+ case values(headers, normalized) do
+ [] -> :error
+ [value] -> {:ok, value}
+ [_ | _] = many -> {:ok, many}
+ end
+ end
+
+ @impl Access
+ def get_and_update(%__MODULE__{} = headers, key, fun) do
+ current =
+ case values(headers, key) do
+ [] -> nil
+ [value] -> value
+ [_ | _] = many -> many
+ end
+
+ case fun.(current) do
+ :pop ->
+ {current, delete(headers, key)}
+
+ {get, update} ->
+ updated =
+ case update do
+ nil -> delete(headers, key)
+ _ -> put(headers, key, update)
+ end
+
+ {get, updated}
+ end
+ end
+
+ @impl Access
+ def pop(%__MODULE__{} = headers, key) do
+ current =
+ case values(headers, key) do
+ [] -> nil
+ [value] -> value
+ [_ | _] = many -> many
+ end
+
+ {current, delete(headers, key)}
+ end
+end
+
+defimpl Enumerable, for: Mail.Headers do
+ def count(%Mail.Headers{items: items}), do: {:ok, length(items)}
+
+ def member?(%Mail.Headers{items: items}, element), do: {:ok, element in items}
+
+ def slice(%Mail.Headers{items: items}) do
+ {:ok, length(items), fn start, len -> Enum.slice(items, start, len) end}
+ end
+
+ def reduce(%Mail.Headers{items: items}, acc, fun), do: Enumerable.List.reduce(items, acc, fun)
+end
diff --git a/lib/mail/message.ex b/lib/mail/message.ex
index c9a97429..fb6af046 100644
--- a/lib/mail/message.ex
+++ b/lib/mail/message.ex
@@ -1,5 +1,5 @@
defmodule Mail.Message do
- defstruct headers: %{},
+ defstruct headers: Mail.Headers.new(),
body: nil,
parts: [],
multipart: false
@@ -45,37 +45,47 @@ defmodule Mail.Message do
## Examples
- iex> message = %Mail.Message{headers: %{"content-type" => ["text/plain", {"charset", "UTF-8"}]}}
+ iex> message = Mail.Message.put_header(%Mail.Message{}, "content-type", ["text/plain", {"charset", "UTF-8"}])
iex> Mail.Message.match_content_type?(message, ~r/text/)
true
- iex> message = %Mail.Message{headers: %{"content-type" => ["text/plain", {"charset", "UTF-8"}]}}
+ iex> message = Mail.Message.put_header(%Mail.Message{}, "content-type", "text/plain")
iex> Mail.Message.match_content_type?(message, "text/html")
false
"""
def match_content_type?(message, string_or_regex)
def match_content_type?(message, %Regex{} = regex) do
- content_type =
- get_content_type(message)
- |> List.first()
-
+ [content_type | _] = get_content_type(message)
Regex.match?(regex, content_type)
end
def match_content_type?(message, type) when is_binary(type),
do: match_content_type?(message, ~r/#{type}/)
- def match_body_text(%{headers: %{"content-disposition" => ["attachment" | _]}}), do: false
- def match_body_text(message), do: Mail.Message.match_content_type?(message, "text/plain")
+ def match_body_text(message) do
+ case get_header!(message, :content_disposition) do
+ ["attachment" | _] -> false
+ _ -> Mail.Message.match_content_type?(message, "text/plain")
+ end
+ end
@doc """
Add a new header key/value pair
+ This function will replace all existing headers with the same name.
+
## Examples
iex> Mail.Message.put_header(%Mail.Message{}, :content_type, "text/plain")
- %Mail.Message{headers: %{"content-type" => "text/plain"}}
+ iex> |> Mail.Message.get_content_type()
+ ["text/plain"]
+
+ iex> message = %Mail.Message{}
+ iex> message = Mail.Message.put_header(message, :content_type, "text/plain")
+ iex> message = Mail.Message.put_header(message, :content_type, "text/html")
+ iex> Mail.Message.get_content_type(message)
+ ["text/html"]
The individual headers will be in the `headers` field on the
`%Mail.Message{}` struct
@@ -83,39 +93,93 @@ defmodule Mail.Message do
def put_header(message, key, content) when not is_binary(key),
do: put_header(message, to_string(key), content)
- def put_header(message, key, content),
- do: %{message | headers: Map.put(message.headers, fix_header(key), content)}
+ def put_header(message, key, content) do
+ %{message | headers: Mail.Headers.put(message.headers, key, content)}
+ end
+
+ @doc """
+ Prepends a new header key/value pair (allows duplicates).
- def put_headers(message, headers) do
- Enum.reduce(headers, message, fn {key, value}, message ->
- put_header(message, key, value)
- end)
+ ## Examples
+
+ iex> message = %Mail.Message{}
+ iex> message = Mail.Message.prepend_header(message, :received, "Mon, 11 May 2026 12:00:00 +0000")
+ iex> message = Mail.Message.prepend_header(message, :received, "Mon, 11 May 2026 12:00:01 +0000")
+ iex> Mail.Message.get_header(message, "received")
+ ["Mon, 11 May 2026 12:00:01 +0000", "Mon, 11 May 2026 12:00:00 +0000"]
+ """
+ def prepend_header(message, key, content) when not is_binary(key),
+ do: prepend_header(message, to_string(key), content)
+
+ def prepend_header(message, key, content) do
+ headers = Mail.Headers.prepend(message.headers, key, content)
+ %{message | headers: headers}
end
- def get_header(message, key) when not is_binary(key),
- do: get_header(message, to_string(key))
+ @doc """
+ Gets all the values for a given header name
+
+ ## Examples
- def get_header(message, key),
- do: Map.get(message.headers, fix_header(key))
+ iex> message = %Mail.Message{}
+ iex> message = Mail.Message.put_header(message, :received, "Mon, 11 May 2026 12:00:00 +0000")
+ iex> message = Mail.Message.prepend_header(message, :received, "Mon, 11 May 2026 12:00:01 +0000")
+ iex> Mail.Message.get_header(message, "received")
+ ["Mon, 11 May 2026 12:00:01 +0000", "Mon, 11 May 2026 12:00:00 +0000"]
+ """
+ def get_header(message, key) do
+ Mail.Headers.values(message.headers, key)
+ end
+
+ @doc """
+ Gets a single header value or `nil`, raising if multiple values exist.
+
+ ## Examples
+
+ iex> message = %Mail.Message{}
+ iex> Mail.Message.get_header!(message, "content-type")
+ nil
+
+ iex> message = %Mail.Message{}
+ iex> message = Mail.Message.put_header(message, :content_type, "text/plain")
+ iex> Mail.Message.get_header!(message, "content-type")
+ "text/plain"
+
+ iex> message = %Mail.Message{}
+ iex> message = Mail.Message.put_header(message, :content_type, "text/plain")
+ iex> message = Mail.Message.prepend_header(message, :content_type, "text/html")
+ iex> Mail.Message.get_header!(message, "content-type")
+ ** (ArgumentError) multiple header values for "content-type"
+ """
+ def get_header!(message, key) when not is_binary(key),
+ do: get_header!(message, to_string(key))
+
+ def get_header!(message, key) do
+ Mail.Headers.get_single!(message.headers, key)
+ end
@doc """
Deletes a specific header key
## Examples
- iex> Mail.Message.delete_header(%Mail.Message{headers: %{"foo" => "bar"}}, :foo)
- %Mail.Message{headers: %{}}
+ iex> message = Mail.Message.put_header(%Mail.Message{}, :foo, "bar")
+ iex> message = Mail.Message.delete_header(message, :foo)
+ iex> Mail.Message.has_header?(message, :foo)
+ false
"""
def delete_header(message, header),
- do: %{message | headers: Map.delete(message.headers, fix_header(header))}
+ do: %{message | headers: Mail.Headers.delete(message.headers, header)}
@doc """
Deletes a list of headers
## Examples
- iex> Mail.Message.delete_headers(%Mail.Message{headers: %{"foo" => "bar", "baz" => "qux"}}, [:foo, :baz])
- %Mail.Message{headers: %{}}
+ iex> message = %Mail.Message{} |> Mail.Message.put_header(:foo, "bar") |> Mail.Message.put_header(:baz, "qux")
+ iex> message = Mail.Message.delete_headers(message, [:foo, :baz])
+ iex> Mail.Message.has_header?(message, :foo)
+ false
"""
def delete_headers(message, headers)
def delete_headers(message, []), do: message
@@ -123,14 +187,17 @@ defmodule Mail.Message do
def delete_headers(message, [header | tail]),
do: delete_headers(delete_header(message, header), tail)
- def has_header?(message, header),
- do: Map.has_key?(message.headers, fix_header(header))
+ @doc """
+ Checks if a header exists
- defp fix_header(key) when not is_binary(key),
- do: fix_header(to_string(key))
+ ## Examples
- defp fix_header(key),
- do: key |> String.downcase() |> String.replace("_", "-")
+ iex> message = %Mail.Message{}
+ iex> Mail.Message.has_header?(message, :foo)
+ false
+ """
+ def has_header?(message, header),
+ do: Mail.Headers.has?(message.headers, header)
@doc """
Add a new `content-type` header
@@ -140,10 +207,12 @@ defmodule Mail.Message do
## Examples
iex> Mail.Message.put_content_type(%Mail.Message{}, "text/plain")
- %Mail.Message{headers: %{"content-type" => ["text/plain"]}}
+ iex> |> Mail.Message.get_content_type()
+ ["text/plain"]
iex> Mail.Message.put_content_type(%Mail.Message{}, ["text/plain", {"charset", "UTF-8"}])
- %Mail.Message{headers: %{"content-type" => ["text/plain", {"charset", "UTF-8"}]}}
+ iex> |> Mail.Message.get_content_type()
+ ["text/plain", {"charset", "UTF-8"}]
"""
def put_content_type(message, content_type) when is_binary(content_type),
do: put_content_type(message, [content_type])
@@ -162,16 +231,17 @@ defmodule Mail.Message do
iex> Mail.Message.get_content_type(%Mail.Message{})
[""]
- iex> Mail.Message.get_content_type(%Mail.Message{headers: %{"content-type" => "text/plain"}})
+ iex> message = Mail.Message.put_header(%Mail.Message{}, "content-type", "text/plain")
+ iex> Mail.Message.get_content_type(message)
["text/plain"]
- iex> Mail.Message.get_content_type(%Mail.Message{headers: %{"content-type" => ["multipart/mixed", {"boundary", "foobar"}]}})
+ iex> message = Mail.Message.put_header(%Mail.Message{}, "content-type", ["multipart/mixed", {"boundary", "foobar"}])
+ iex> Mail.Message.get_content_type(message)
["multipart/mixed", {"boundary", "foobar"}]
"""
- def get_content_type(message),
- do:
- (get_header(message, :content_type) || "")
- |> List.wrap()
+ def get_content_type(message) do
+ List.wrap(get_header!(message, :content_type) || "")
+ end
@doc """
Adds a boundary value to the `content_type` header
@@ -182,10 +252,12 @@ defmodule Mail.Message do
## Examples
iex> Mail.Message.put_boundary(%Mail.Message{}, "foobar")
- %Mail.Message{headers: %{"content-type" => ["", {"boundary", "foobar"}]}}
+ iex> |> Mail.Message.get_content_type()
+ ["", {"boundary", "foobar"}]
- iex> Mail.Message.put_boundary(%Mail.Message{headers: %{"content-type" => ["multipart/mixed", {"boundary", "bazqux"}]}}, "foobar")
- %Mail.Message{headers: %{"content-type" => ["multipart/mixed", {"boundary", "foobar"}]}}
+ iex> message = Mail.Message.put_header(%Mail.Message{}, "content-type", ["multipart/mixed", {"boundary", "bazqux"}])
+ iex> Mail.Message.put_boundary(message, "foobar") |> Mail.Message.get_content_type()
+ ["multipart/mixed", {"boundary", "foobar"}]
"""
def put_boundary(message, boundary) do
content_type =
@@ -202,10 +274,12 @@ defmodule Mail.Message do
## Examples
- iex> Mail.Message.get_boundary(%Mail.Message{headers: %{"content-type" => ["multipart/mixed", {"boundary", "foobar"}]}})
+ iex> message = Mail.Message.put_header(%Mail.Message{}, "content-type", ["multipart/mixed", {"boundary", "foobar"}])
+ iex> Mail.Message.get_boundary(message)
"foobar"
- iex> Mail.Message.get_boundary(%Mail.Message{headers: %{"content-type" => ["multipart/mixed", {"boundary", "ASDFSHNEW3473423"}]}})
+ iex> message = Mail.Message.put_header(%Mail.Message{}, "content-type", ["multipart/mixed", {"boundary", "ASDFSHNEW3473423"}])
+ iex> Mail.Message.get_boundary(message)
"ASDFSHNEW3473423"
"""
def get_boundary(message) do
@@ -228,7 +302,7 @@ defmodule Mail.Message do
## Examples
iex> Mail.Message.put_body(%Mail.Message{}, "Some Data")
- %Mail.Message{body: "Some Data", headers: %{}}
+ %Mail.Message{body: "Some Data"}
"""
def put_body(part, body),
do: put_in(part.body, body)
@@ -238,11 +312,13 @@ defmodule Mail.Message do
## Examples
- iex> Mail.Message.build_text("Some text")
- %Mail.Message{body: "Some text", headers: %{"content-type" => ["text/plain", {"charset", "UTF-8"}], "content-transfer-encoding" => :quoted_printable}}
+ iex> %Mail.Message{body: "Some text"} = message = Mail.Message.build_text("Some text")
+ iex> Mail.Message.get_content_type(message)
+ ["text/plain", {"charset", "UTF-8"}]
- iex> Mail.Message.build_text("Some text", charset: "us-ascii")
- %Mail.Message{body: "Some text", headers: %{"content-type" => ["text/plain", {"charset", "us-ascii"}], "content-transfer-encoding" => :quoted_printable}}
+ iex> %Mail.Message{body: "Some text"} = message = Mail.Message.build_text("Some text", charset: "us-ascii")
+ iex> Mail.Message.get_content_type(message)
+ ["text/plain", {"charset", "us-ascii"}]
## Options
@@ -269,10 +345,14 @@ defmodule Mail.Message do
## Examples
iex> Mail.Message.build_html("
Some HTML
")
- %Mail.Message{body: "Some HTML
", headers: %{"content-type" => ["text/html", {"charset", "UTF-8"}], "content-transfer-encoding" => :quoted_printable}}
+ iex> %Mail.Message{body: "Some HTML
"} = message = Mail.Message.build_html("Some HTML
")
+ iex> Mail.Message.get_content_type(message)
+ ["text/html", {"charset", "UTF-8"}]
iex> Mail.Message.build_html("Some HTML
", charset: "UTF-8")
- %Mail.Message{body: "Some HTML
", headers: %{"content-type" => ["text/html", {"charset", "UTF-8"}], "content-transfer-encoding" => :quoted_printable}}
+ iex> %Mail.Message{body: "Some HTML
"} = message = Mail.Message.build_html("Some HTML
", charset: "UTF-8")
+ iex> Mail.Message.get_content_type(message)
+ ["text/html", {"charset", "UTF-8"}]
## Options
@@ -311,11 +391,15 @@ defmodule Mail.Message do
## Examples
- iex> message = Mail.Message.build_attachment("README.md")
- %Mail.Message{body: <<"# Mail\\n", _::binary>>, headers: %{"content-type" => ["text/markdown"], "content-disposition" => ["attachment", {"filename", "README.md"}], "content-transfer-encoding" => :base64}} = message
+ iex> %Mail.Message{body: <<"# Mail\\n", _::binary>>} = message = Mail.Message.build_attachment("README.md")
+ iex> Mail.Message.get_content_type(message)
+ ["text/markdown"]
+ iex> Mail.Message.get_header!(message, "content-disposition")
+ ["attachment", {"filename", "README.md"}]
- iex> message = Mail.Message.build_attachment({"README.md", "file contents"})
- %Mail.Message{body: "file contents", headers: %{"content-type" => ["text/markdown"], "content-disposition" => ["attachment", {"filename", "README.md"}], "content-transfer-encoding" => :base64}} = message
+ iex> %Mail.Message{body: "file contents"} = message = Mail.Message.build_attachment({"README.md", "file contents"})
+ iex> {message.body, Mail.Message.get_header!(message, "content-disposition"), Mail.Message.get_header!(message, "content-transfer-encoding")}
+ {"file contents", ["attachment", {"filename", "README.md"}], :base64}
## Options
@@ -354,19 +438,23 @@ defmodule Mail.Message do
## Examples
- iex> message = Mail.Message.put_attachment(%Mail.Message{}, "README.md")
- %Mail.Message{body: <<"# Mail\\n", _::binary>>, headers: %{"content-type" => ["text/markdown"], "content-disposition" => ["attachment", {"filename", "README.md"}], "content-transfer-encoding" => :base64}} = message
+ iex> %Mail.Message{body: <<"# Mail\\n", _::binary>>} = message = Mail.Message.put_attachment(%Mail.Message{}, "README.md")
+ iex> Mail.Message.get_header!(message, "content-disposition")
+ ["attachment", {"filename", "README.md"}]
- iex> Mail.Message.put_attachment(%Mail.Message{}, {"README.md", "file contents"})
- %Mail.Message{body: "file contents", headers: %{"content-type" => ["text/markdown"], "content-disposition" => ["attachment", {"filename", "README.md"}], "content-transfer-encoding" => :base64}}
+ iex> %Mail.Message{body: "file contents"} = message = Mail.Message.put_attachment(%Mail.Message{}, {"README.md", "file contents"})
+ iex> Mail.Message.get_content_type(message)
+ ["text/markdown"]
### Adding custom headers
- iex> message =Mail.Message.put_attachment(%Mail.Message{}, "README.md", headers: [content_id: "attachment-id"])
- %Mail.Message{body: <<"# Mail\\n", _::binary>>, headers: %{"content-type" => ["text/markdown"], "content-disposition" => ["attachment", {"filename", "README.md"}], "content-transfer-encoding" => :base64, "content-id" => "attachment-id"}} = message
+ iex> %Mail.Message{body: <<"# Mail\\n", _::binary>>} = message = Mail.Message.put_attachment(%Mail.Message{}, "README.md", headers: [content_id: "attachment-id"])
+ iex> Mail.Message.get_header!(message, "content-id")
+ "attachment-id"
- iex> message = Mail.Message.put_attachment(%Mail.Message{}, {"README.md", "file contents"}, headers: [content_id: "attachment-id"])
- %Mail.Message{body: "file contents", headers: %{"content-type" => ["text/markdown"], "content-disposition" => ["attachment", {"filename", "README.md"}], "content-transfer-encoding" => :base64, "content-id" => "attachment-id"}} = message
+ iex> %Mail.Message{body: "file contents"} = message = Mail.Message.put_attachment(%Mail.Message{}, {"README.md", "file contents"}, headers: [content_id: "attachment-id"])
+ iex> Mail.Message.get_header!(message, "content-id")
+ "attachment-id"
"""
def put_attachment(message, path_or_file_tuple, opts \\ [])
@@ -410,7 +498,7 @@ defmodule Mail.Message do
:inline -> ["inline"]
end
- case List.wrap(get_header(message, :content_disposition)) do
+ case List.wrap(get_header!(message, :content_disposition)) do
[disposition | _] -> disposition in types
_ -> false
end
diff --git a/lib/mail/parsers/rfc_2822.ex b/lib/mail/parsers/rfc_2822.ex
index e4d7df72..60fcf666 100644
--- a/lib/mail/parsers/rfc_2822.ex
+++ b/lib/mail/parsers/rfc_2822.ex
@@ -17,8 +17,9 @@ defmodule Mail.Parsers.RFC2822 do
...> This is the body!\r
...> It has more than one line\r
...> \"""
- iex> Mail.Parsers.RFC2822.parse(message)
- %Mail.Message{body: "This is the body!\r\nIt has more than one line", headers: %{"to" => ["user@example.com"], "from" => "me@example.com", "subject" => "Test Email", "content-type" => ["text/plain", {"foo", "bar"}, {"baz", "qux"}]}}
+ iex> parsed = Mail.Parsers.RFC2822.parse(message)
+ iex> {Mail.get_to!(parsed), Mail.get_from!(parsed), Mail.get_subject!(parsed)}
+ {["user@example.com"], "me@example.com", "Test Email"}
"""
@months ~w(jan feb mar apr may jun jul aug sep oct nov dec)
@@ -380,7 +381,7 @@ defmodule Mail.Parsers.RFC2822 do
headers =
Enum.reduce(headers, message.headers, fn header, headers ->
{key, value} = parse_header(header, opts)
- put_header(headers, key, value)
+ Mail.Headers.append(headers, key, value)
end)
Map.put(message, :headers, headers)
@@ -412,14 +413,8 @@ defmodule Mail.Parsers.RFC2822 do
defp is_params([{_key, _value} | params]), do: is_params(params)
defp is_params([_ | _]), do: false
- defp put_header(headers, "received" = key, value),
- do: Map.update(headers, key, [value], &[value | &1])
-
- defp put_header(headers, key, value),
- do: Map.put(headers, key, value)
-
defp mark_multipart(message),
- do: Map.put(message, :multipart, multipart?(message.headers))
+ do: Map.put(message, :multipart, multipart?(message))
defp parse_header_value(key, " " <> value),
do: parse_header_value(key, value)
@@ -688,7 +683,7 @@ defmodule Mail.Parsers.RFC2822 do
do: <>
defp parse_body(%Mail.Message{multipart: true} = message, lines, opts) do
- content_type = message.headers["content-type"]
+ content_type = Mail.Message.get_content_type(message)
boundary = Mail.Proplist.get(content_type, "boundary")
parts =
@@ -829,20 +824,20 @@ defmodule Mail.Parsers.RFC2822 do
{charset, language, value}
end
- defp multipart?(headers) do
- content_type = headers["content-type"]
+ defp multipart?(message) do
+ content_type = Mail.Message.get_content_type(message)
- !!case content_type do
- nil -> nil
- type when is_binary(type) -> nil
- content_type -> Mail.Proplist.get(content_type, "boundary")
+ case content_type do
+ [] -> false
+ type when is_binary(type) -> false
+ content_type -> Mail.Proplist.get(content_type, "boundary") != nil
end
end
defp decode(body, message, opts) do
- content_type = message.headers["content-type"]
+ content_type = Mail.Message.get_content_type(message)
charset = Mail.Proplist.get(content_type, "charset")
- transfer_encoding = Mail.Message.get_header(message, "content-transfer-encoding")
+ transfer_encoding = Mail.Message.get_header!(message, "content-transfer-encoding")
decoded = Mail.Encoder.decode(body, transfer_encoding)
if charset do
diff --git a/lib/mail/renderers/rfc_2822.ex b/lib/mail/renderers/rfc_2822.ex
index ce3e4d24..c5d2658e 100644
--- a/lib/mail/renderers/rfc_2822.ex
+++ b/lib/mail/renderers/rfc_2822.ex
@@ -191,9 +191,10 @@ defmodule Mail.Renderers.RFC2822 do
"""
def render_headers(headers, blacklist \\ [])
- def render_headers(map, blacklist) when is_map(map) do
- map
- |> Map.to_list()
+ def render_headers(%Mail.Headers{} = headers, blacklist) do
+ headers
+ |> Mail.Headers.to_list()
+ |> Enum.reverse()
|> render_headers(blacklist)
end
@@ -290,6 +291,8 @@ defmodule Mail.Renderers.RFC2822 do
end
defp reorganize(%Mail.Message{multipart: true, headers: headers} = message) do
+ message = %{message | headers: Mail.Headers.new()}
+
{text_parts, attachments} =
message.parts
|> Enum.split_with(&match_content_type?(&1, ~r/text\/(plain|html)/))
@@ -330,10 +333,16 @@ defmodule Mail.Renderers.RFC2822 do
end
end
- Mail.Message.put_headers(message, headers)
+ headers =
+ Mail.Headers.prepend_headers(
+ message.headers,
+ Mail.Headers.delete(headers, "content-type")
+ )
+
+ %{message | headers: headers}
end
defp encode(body, message) do
- Mail.Encoder.encode(body, Mail.Message.get_header(message, "content-transfer-encoding"))
+ Mail.Encoder.encode(body, Mail.Message.get_header!(message, "content-transfer-encoding"))
end
end
diff --git a/lib/mail/test_assertions.ex b/lib/mail/test_assertions.ex
index f89ca8e0..4c17d525 100644
--- a/lib/mail/test_assertions.ex
+++ b/lib/mail/test_assertions.ex
@@ -29,13 +29,16 @@ defmodule Mail.TestAssertions do
actual = normalize_boundary(actual)
expected = normalize_boundary(expected)
- Enum.each(actual, fn {key, value} ->
- cond do
- value != expected[key] ->
- raise(ExUnit.AssertionError, message: "header key `#{key}` is not equal")
+ actual
+ |> Mail.Headers.to_list()
+ |> Enum.map(&elem(&1, 0))
+ |> Enum.uniq()
+ |> Enum.each(fn key ->
+ actual_values = Mail.Headers.values(actual, key)
+ expected_values = Mail.Headers.values(expected, key)
- true ->
- nil
+ if actual_values != expected_values do
+ raise(ExUnit.AssertionError, message: "header key `#{key}` is not equal")
end
end)
end
@@ -63,7 +66,8 @@ defmodule Mail.TestAssertions do
defp normalize_boundary(headers) do
content_type =
- headers["content-type"]
+ headers
+ |> Mail.Headers.get_single!("content-type")
|> List.wrap()
content_type
@@ -74,7 +78,7 @@ defmodule Mail.TestAssertions do
_boundary ->
content_type = Mail.Proplist.put(content_type, "boundary", "")
- put_in(headers, ["content-type"], content_type)
+ Mail.Headers.put(headers, "content-type", content_type)
end
end
end
diff --git a/test/mail/headers_test.exs b/test/mail/headers_test.exs
new file mode 100644
index 00000000..8e631307
--- /dev/null
+++ b/test/mail/headers_test.exs
@@ -0,0 +1,150 @@
+defmodule Mail.HeadersTest do
+ use ExUnit.Case, async: true
+
+ test "normalizes header names" do
+ assert Mail.Headers.normalize_name("Content_Type") == "content-type"
+ assert Mail.Headers.normalize_name(:content_type) == "content-type"
+ end
+
+ test "put/3 replaces all instances and keeps order" do
+ headers =
+ Mail.Headers.new()
+ |> Mail.Headers.append("x-test", "a")
+ |> Mail.Headers.append("x-test", "b")
+ |> Mail.Headers.put("x-test", "c")
+
+ assert Mail.Headers.to_list(headers) == [{"x-test", "c"}]
+ assert Mail.Headers.values(headers, "x-test") == ["c"]
+ end
+
+ test "prepend/3 allows duplicates" do
+ headers =
+ Mail.Headers.new()
+ |> Mail.Headers.append("x-test", "a")
+ |> Mail.Headers.prepend("x-test", "b")
+
+ assert Mail.Headers.to_list(headers) == [{"x-test", "b"}, {"x-test", "a"}]
+ assert Mail.Headers.values(headers, "x-test") == ["b", "a"]
+ end
+
+ test "prepend_headers/2 preserves source order and duplicates before target" do
+ target =
+ Mail.Headers.new()
+ |> Mail.Headers.append("content-type", "multipart/mixed")
+
+ source =
+ Mail.Headers.new()
+ |> Mail.Headers.append("received", "hop-a")
+ |> Mail.Headers.append("received", "hop-b")
+ |> Mail.Headers.append("subject", "hi")
+
+ merged = Mail.Headers.prepend_headers(target, source)
+
+ assert Mail.Headers.to_list(merged) == [
+ {"received", "hop-a"},
+ {"received", "hop-b"},
+ {"subject", "hi"},
+ {"content-type", "multipart/mixed"}
+ ]
+ end
+
+ test "Access: missing returns nil, single returns value, multiple returns list" do
+ empty = Mail.Headers.new()
+ assert empty["x-test"] == nil
+
+ single = Mail.Headers.put(empty, "x-test", "a")
+ assert single["x-test"] == "a"
+
+ multiple =
+ single
+ |> Mail.Headers.append("x-test", "b")
+
+ assert multiple["x-test"] == ["a", "b"]
+ end
+
+ describe "Access callbacks" do
+ test "fetch/2" do
+ headers =
+ Mail.Headers.new()
+ |> Mail.Headers.append("x-test", "a")
+ |> Mail.Headers.append("x-test", "b")
+ |> Mail.Headers.append("y-test", "y")
+
+ assert Access.fetch(headers, "missing") == :error
+ assert Access.fetch(headers, "y-test") == {:ok, "y"}
+ assert Access.fetch(headers, "x-test") == {:ok, ["a", "b"]}
+ end
+
+ test "get_and_update/3" do
+ headers =
+ Mail.Headers.new()
+ |> Mail.Headers.append("x-test", "a")
+ |> Mail.Headers.append("x-test", "b")
+ |> Mail.Headers.append("y-test", "y")
+
+ {get, updated} =
+ Access.get_and_update(headers, "y-test", fn current ->
+ {current, "y2"}
+ end)
+
+ assert get == "y"
+ assert updated["y-test"] == "y2"
+ end
+
+ test "pop/2" do
+ headers =
+ Mail.Headers.new()
+ |> Mail.Headers.append("x-test", "a")
+ |> Mail.Headers.append("x-test", "b")
+ |> Mail.Headers.append("y-test", "y")
+
+ {popped, after_pop} = Access.pop(headers, "x-test")
+ assert popped == ["a", "b"]
+ assert after_pop["x-test"] == nil
+ end
+ end
+
+ describe "Enumerable" do
+ test "Enum.to_list/1" do
+ headers =
+ Mail.Headers.new()
+ |> Mail.Headers.append("a", 1)
+ |> Mail.Headers.append("b", 2)
+
+ assert Enum.to_list(headers) == [{"a", 1}, {"b", 2}]
+ end
+
+ test "Enum.count/1" do
+ headers =
+ Mail.Headers.new()
+ |> Mail.Headers.append("a", 1)
+ |> Mail.Headers.append("b", 2)
+
+ assert Enum.count(headers) == 2
+ end
+
+ test "Enum.member?/2" do
+ headers =
+ Mail.Headers.new()
+ |> Mail.Headers.append("a", 1)
+ |> Mail.Headers.append("b", 2)
+
+ assert Enum.member?(headers, {"a", 1})
+ refute Enum.member?(headers, {"a", 2})
+ end
+ end
+
+ test "get_single!/2 returns singleton or nil and raises on duplicates" do
+ headers = Mail.Headers.new()
+ assert Mail.Headers.get_single!(headers, "x-test") == nil
+
+ headers = Mail.Headers.put(headers, "x-test", "a")
+ assert Mail.Headers.get_single!(headers, "x-test") == "a"
+
+ headers = Mail.Headers.append(headers, "x-test", "b")
+
+ assert_raise ArgumentError, fn ->
+ Mail.Headers.get_single!(headers, "x-test")
+ end
+ end
+end
diff --git a/test/mail/message_test.exs b/test/mail/message_test.exs
index 3cf9344b..d9540f3a 100644
--- a/test/mail/message_test.exs
+++ b/test/mail/message_test.exs
@@ -20,25 +20,29 @@ defmodule Mail.MessageTest do
test "put_header" do
message = Mail.Message.put_header(%Mail.Message{}, :test, "test content")
- assert Mail.Message.get_header(message, :test) == "test content"
+ assert Mail.Message.get_header(message, :test) == ["test content"]
end
test "get_header" do
- message = %Mail.Message{headers: %{"foo" => "bar"}}
- assert Mail.Message.get_header(message, :foo) == "bar"
+ message = Mail.Message.put_header(%Mail.Message{}, :foo, "bar")
+ assert Mail.Message.get_header(message, :foo) == ["bar"]
end
test "delete_header" do
- message = Mail.Message.delete_header(%Mail.Message{headers: %{"foo" => "bar"}}, :foo)
- refute Map.has_key?(message.headers, :foo)
+ message =
+ %Mail.Message{}
+ |> Mail.Message.put_header(:foo, "bar")
+ |> Mail.Message.delete_header(:foo)
+
+ refute Mail.Message.has_header?(message, :foo)
end
test "delete_headers" do
message =
- Mail.Message.delete_headers(%Mail.Message{headers: %{"foo" => "bar", "baz" => "qux"}}, [
- :foo,
- :baz
- ])
+ %Mail.Message{}
+ |> Mail.Message.put_header(:foo, "bar")
+ |> Mail.Message.put_header(:baz, "qux")
+ |> Mail.Message.delete_headers([:foo, :baz])
refute Mail.Message.has_header?(message, :foo)
refute Mail.Message.has_header?(message, :baz)
@@ -46,14 +50,14 @@ defmodule Mail.MessageTest do
test "put_content_type" do
message = Mail.Message.put_content_type(%Mail.Message{}, "multipart/mixed")
- assert Mail.Message.get_header(message, :content_type) == ["multipart/mixed"]
+ assert Mail.Message.get_header!(message, :content_type) == ["multipart/mixed"]
end
test "get_content_type" do
- message = %Mail.Message{headers: %{"content-type" => "multipart/mixed"}}
+ message = Mail.Message.put_header(%Mail.Message{}, "content-type", "multipart/mixed")
assert Mail.Message.get_content_type(message) == ["multipart/mixed"]
- message = %Mail.Message{headers: %{"content-type" => ["multipart/mixed"]}}
+ message = Mail.Message.put_header(%Mail.Message{}, "content-type", ["multipart/mixed"])
assert Mail.Message.get_content_type(message) == ["multipart/mixed"]
message = %Mail.Message{}
@@ -65,7 +69,7 @@ defmodule Mail.MessageTest do
boundary =
message
- |> Mail.Message.get_header(:content_type)
+ |> Mail.Message.get_header!(:content_type)
|> Mail.Proplist.get("boundary")
assert boundary == "customboundary"
@@ -74,7 +78,7 @@ defmodule Mail.MessageTest do
Mail.Message.put_header(%Mail.Message{}, :content_type, ["multipart/mixed"])
|> Mail.Message.put_boundary("customboundary")
- assert Mail.Message.get_header(message, :content_type) == [
+ assert Mail.Message.get_header!(message, :content_type) == [
"multipart/mixed",
{"boundary", "customboundary"}
]
@@ -83,7 +87,7 @@ defmodule Mail.MessageTest do
Mail.Message.put_header(%Mail.Message{}, :content_type, "multipart/mixed")
|> Mail.Message.put_boundary("customboundary")
- assert Mail.Message.get_header(message, :content_type) == [
+ assert Mail.Message.get_header!(message, :content_type) == [
"multipart/mixed",
{"boundary", "customboundary"}
]
@@ -104,28 +108,28 @@ defmodule Mail.MessageTest do
test "build_text" do
message = Mail.Message.build_text("Some text")
assert Mail.Message.get_content_type(message) == ["text/plain", {"charset", "UTF-8"}]
- assert Mail.Message.get_header(message, :content_transfer_encoding) == :quoted_printable
+ assert Mail.Message.get_header!(message, :content_transfer_encoding) == :quoted_printable
assert message.body == "Some text"
end
test "build_text when given charset" do
message = Mail.Message.build_text("Some text", charset: "US-ASCII")
assert Mail.Message.get_content_type(message) == ["text/plain", {"charset", "US-ASCII"}]
- assert Mail.Message.get_header(message, :content_transfer_encoding) == :quoted_printable
+ assert Mail.Message.get_header!(message, :content_transfer_encoding) == :quoted_printable
assert message.body == "Some text"
end
test "build_html" do
message = Mail.Message.build_html("Some HTML
")
assert Mail.Message.get_content_type(message) == ["text/html", {"charset", "UTF-8"}]
- assert Mail.Message.get_header(message, :content_transfer_encoding) == :quoted_printable
+ assert Mail.Message.get_header!(message, :content_transfer_encoding) == :quoted_printable
assert message.body == "Some HTML
"
end
test "build_html when given charset" do
message = Mail.Message.build_html("Some HTML
", charset: "US-ASCII")
assert Mail.Message.get_content_type(message) == ["text/html", {"charset", "US-ASCII"}]
- assert Mail.Message.get_header(message, :content_transfer_encoding) == :quoted_printable
+ assert Mail.Message.get_header!(message, :content_transfer_encoding) == :quoted_printable
assert message.body == "Some HTML
"
end
@@ -135,12 +139,12 @@ defmodule Mail.MessageTest do
assert Mail.Message.get_content_type(part) == ["text/markdown"]
- assert Mail.Message.get_header(part, :content_disposition) == [
+ assert Mail.Message.get_header!(part, :content_disposition) == [
"attachment",
{"filename", "README.md"}
]
- assert Mail.Message.get_header(part, :content_transfer_encoding) == :base64
+ assert Mail.Message.get_header!(part, :content_transfer_encoding) == :base64
assert part.body == file_content
end
@@ -150,13 +154,13 @@ defmodule Mail.MessageTest do
assert Mail.Message.get_content_type(part) == ["text/markdown"]
- assert Mail.Message.get_header(part, :content_disposition) == [
+ assert Mail.Message.get_header!(part, :content_disposition) == [
"attachment",
{"filename", "README.md"}
]
- assert Mail.Message.get_header(part, :content_transfer_encoding) == :base64
- assert Mail.Message.get_header(part, :content_id) == "attachment-id"
+ assert Mail.Message.get_header!(part, :content_transfer_encoding) == :base64
+ assert Mail.Message.get_header!(part, :content_id) == "attachment-id"
assert part.body == file_content
end
@@ -166,12 +170,12 @@ defmodule Mail.MessageTest do
assert Mail.Message.get_content_type(part) == ["text/markdown"]
- assert Mail.Message.get_header(part, :content_disposition) == [
+ assert Mail.Message.get_header!(part, :content_disposition) == [
"attachment",
{"filename", "README.md"}
]
- assert Mail.Message.get_header(part, :content_transfer_encoding) == :base64
+ assert Mail.Message.get_header!(part, :content_transfer_encoding) == :base64
assert part.body == file_content
end
@@ -185,13 +189,13 @@ defmodule Mail.MessageTest do
assert Mail.Message.get_content_type(part) == ["text/markdown"]
- assert Mail.Message.get_header(part, :content_disposition) == [
+ assert Mail.Message.get_header!(part, :content_disposition) == [
"attachment",
{"filename", "README.md"}
]
- assert Mail.Message.get_header(part, :content_transfer_encoding) == :base64
- assert Mail.Message.get_header(part, :content_id) == "attachment-id"
+ assert Mail.Message.get_header!(part, :content_transfer_encoding) == :base64
+ assert Mail.Message.get_header!(part, :content_id) == "attachment-id"
assert part.body == file_content
end
@@ -222,7 +226,7 @@ defmodule Mail.MessageTest do
encoded_subject = "=?UTF-8?Q?" <> Mail.Encoders.QuotedPrintable.encode(subject) <> "?="
assert String.contains?(txt, encoded_subject)
- assert %Mail.Message{headers: %{"subject" => ^subject}} = Mail.Parsers.RFC2822.parse(txt)
+ assert Mail.get_subject(Mail.Parsers.RFC2822.parse(txt)) == subject
end
test "UTF-8 in subject (quoted printable with spaces, RFC 2047§4.2 (2))" do
@@ -231,7 +235,7 @@ defmodule Mail.MessageTest do
mail =
"Subject: =?UTF-8?Q?test_" <> Mail.Encoders.QuotedPrintable.encode("😀") <> "_test?=\r\n\r\n"
- assert %Mail.Message{headers: %{"subject" => ^subject}} = Mail.Parsers.RFC2822.parse(mail)
+ assert Mail.get_subject(Mail.Parsers.RFC2822.parse(mail)) == subject
end
test "UTF-8 in addresses" do
@@ -267,9 +271,12 @@ defmodule Mail.MessageTest do
assert String.contains?(message, encoded_header_value)
- assert %Mail.Message{
- headers: %{"content-disposition" => ["attachment", {"filename", ^file_name}]}
- } = Mail.Parsers.RFC2822.parse(message)
+ parsed = Mail.Parsers.RFC2822.parse(message)
+
+ assert Mail.Message.get_header!(parsed, "content-disposition") == [
+ "attachment",
+ {"filename", file_name}
+ ]
end
test "long UTF-8 in subject" do
@@ -285,6 +292,6 @@ defmodule Mail.MessageTest do
"=?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)
+ assert Mail.get_subject!(Mail.Parsers.RFC2822.parse(txt)) == subject
end
end
diff --git a/test/mail/parsers/rfc_2822_test.exs b/test/mail/parsers/rfc_2822_test.exs
index 515cad30..5f029d5f 100644
--- a/test/mail/parsers/rfc_2822_test.exs
+++ b/test/mail/parsers/rfc_2822_test.exs
@@ -16,11 +16,17 @@ defmodule Mail.Parsers.RFC2822Test do
It has more than one line
""")
- assert message.headers["to"] == ["user@example.com"]
- assert message.headers["from"] == "me@example.com"
- assert message.headers["reply-to"] == "otherme@example.com"
- assert message.headers["subject"] == "Test Email"
- assert message.headers["content-type"] == ["text/plain", {"foo", "bar"}, {"baz", "qux"}]
+ assert Mail.Message.get_header!(message, "to") == ["user@example.com"]
+ assert Mail.Message.get_header!(message, "from") == "me@example.com"
+ assert Mail.Message.get_header!(message, "reply-to") == "otherme@example.com"
+ assert Mail.Message.get_header!(message, "subject") == "Test Email"
+
+ assert Mail.Message.get_header!(message, "content-type") == [
+ "text/plain",
+ {"foo", "bar"},
+ {"baz", "qux"}
+ ]
+
assert message.body == "This is the body!\r\nIt has more than one line"
end
@@ -33,10 +39,10 @@ defmodule Mail.Parsers.RFC2822Test do
Subject: Test Email
""")
- assert message.headers["to"] == ["user@example.com"]
- assert message.headers["from"] == "me@example.com"
- assert message.headers["reply-to"] == "otherme@example.com"
- assert message.headers["subject"] == "Test Email"
+ assert Mail.Message.get_header!(message, "to") == ["user@example.com"]
+ assert Mail.Message.get_header!(message, "from") == "me@example.com"
+ assert Mail.Message.get_header!(message, "reply-to") == "otherme@example.com"
+ assert Mail.Message.get_header!(message, "subject") == "Test Email"
assert message.body == ""
end
@@ -63,26 +69,38 @@ defmodule Mail.Parsers.RFC2822Test do
--foobar--
""")
- assert message.headers["to"] == [
+ assert Mail.Message.get_header!(message, "to") == [
{"Test User", "user@example.com"},
{"Other User", "other@example.com"}
]
- assert message.headers["cc"] == [
+ assert Mail.Message.get_header!(message, "cc") == [
{"The Dude", "dude@example.com"},
{"Batman", "batman@example.com"}
]
- assert message.headers["from"] == {"Me", "me@example.com"}
- assert message.headers["reply-to"] == {"OtherMe", "otherme@example.com"}
- assert message.headers["content-type"] == ["multipart/alternative", {"boundary", "foobar"}]
+ assert Mail.Message.get_header!(message, "from") == {"Me", "me@example.com"}
+ assert Mail.Message.get_header!(message, "reply-to") == {"OtherMe", "otherme@example.com"}
+
+ assert Mail.Message.get_header!(message, "content-type") == [
+ "multipart/alternative",
+ {"boundary", "foobar"}
+ ]
[text_part, html_part] = message.parts
- assert text_part.headers["content-type"] == ["text/plain", {"charset", "us-ascii"}]
+ assert Mail.Message.get_header!(text_part, "content-type") == [
+ "text/plain",
+ {"charset", "us-ascii"}
+ ]
+
assert text_part.body == "This is some text\r\n"
- assert html_part.headers["content-type"] == ["text/html", {"charset", "us-ascii"}]
+ assert Mail.Message.get_content_type(html_part) == [
+ "text/html",
+ {"charset", "us-ascii"}
+ ]
+
assert html_part.body == "This is some HTML
"
end
@@ -447,10 +465,8 @@ defmodule Mail.Parsers.RFC2822Test do
assert message.headers["delivered-to"] == "user@example.com"
assert message.headers["received"] == [
- [
- "by 101.102.103.104 with SMTP id abcdefg",
- {"date", ~U"2016-04-01 18:08:31Z"}
- ]
+ "by 101.102.103.104 with SMTP id abcdefg",
+ {"date", ~U"2016-04-01 18:08:31Z"}
]
assert message.headers["x-received"] ==
@@ -479,16 +495,16 @@ defmodule Mail.Parsers.RFC2822Test do
assert message.headers["received"] == [
[
- "from localhost ([127.0.0.1]) by localhost (MyMailSoftware)",
- {"date", ~U"2016-04-01 18:08:31Z"}
+ "from mail3.example.tld ([10.20.30.40]) by mail.fake.tld ([10.10.10.10])",
+ {"date", ~U"2016-04-01 18:09:07Z"}
],
[
"from mail.fake.tld ([10.10.10.10]) by localhost ([127.0.0.1])",
{"date", ~U"2016-04-01 18:08:35Z"}
],
[
- "from mail3.example.tld ([10.20.30.40]) by mail.fake.tld ([10.10.10.10])",
- {"date", ~U"2016-04-01 18:09:07Z"}
+ "from localhost ([127.0.0.1]) by localhost (MyMailSoftware)",
+ {"date", ~U"2016-04-01 18:08:31Z"}
]
]
end
@@ -694,12 +710,12 @@ defmodule Mail.Parsers.RFC2822Test do
""")
assert message.headers["received"] == [
- [
- "by filter0419p1iad2.sendgrid.net with SMTP id filter0419p1iad2-17662-5D0ECF02-32 2019-06-23 00:59:46.828888551 +0000 UTC m=+266323.963383415"
- ],
[
"by 2002:a81:578e:0:0:0:0:0 with SMTP id l136csp2273163ywb",
{"date", ~U"2019-06-23 00:59:49Z"}
+ ],
+ [
+ "by filter0419p1iad2.sendgrid.net with SMTP id filter0419p1iad2-17662-5D0ECF02-32 2019-06-23 00:59:46.828888551 +0000 UTC m=+266323.963383415"
]
]
end
@@ -718,13 +734,13 @@ defmodule Mail.Parsers.RFC2822Test do
""")
assert message.headers["received"] == [
- [
- "from EUR01-HE1-obe.outbound.protection.outlook.com\t (213.199.154.208) by us1.smtp.exclaimer.net (191.237.4.149) with Exclaimer\t Signature Manager ESMTP Proxy us1.smtp.exclaimer.net",
- {"date", ~U"2018-08-06 07:23:18Z"}
- ],
[
"from EUR01-HE1-obe.outbound.protection.outlook.com (213.199.154.213) by us1.smtp.exclaimer.net (191.237.4.149) with Exclaimer Signature Manager ESMTP Proxy us1.smtp.exclaimer.net",
{"date", ~U"2018-08-01 09:49:43Z"}
+ ],
+ [
+ "from EUR01-HE1-obe.outbound.protection.outlook.com\t (213.199.154.208) by us1.smtp.exclaimer.net (191.237.4.149) with Exclaimer\t Signature Manager ESMTP Proxy us1.smtp.exclaimer.net",
+ {"date", ~U"2018-08-06 07:23:18Z"}
]
]
end
@@ -747,25 +763,25 @@ defmodule Mail.Parsers.RFC2822Test do
""")
assert message.headers["received"] == [
+ ["from local-ip[x.x.x.x] by FTGS", {"date", ~U"2014-12-28 18:04:31Z"}],
+ ["from trusted client by mx4.sika.com", {"date", ~U"2017-05-30 15:29:15Z"}],
[
- "from w.x.y.z ([1.1.1.1]) by x.y.local with InterScan Messaging Security Suite",
- {"date", ~U"2019-11-25 11:00:46Z"}
- ],
- [
- "from x.x.x.x\tby Spam Quarantine V01-06377SMG01.x.x.x (x.x.x.x) for ",
- {"date", ~U"2016-04-15 17:22:55Z"}
+ "from freshdesk.com (ec2-x-x-x-x.compute-1.amazonaws.com [x.x.x.x])\tby x.sendgrid.net (SG) with ESMTP id eSJywaprRzabHWQplQP8xw\tfor ",
+ {"date", ~U"2017-06-20 09:44:58Z"}
],
- ["from junghyuk@gbtp.or.kr with Spamsniper 2.96.32 (Processed in 1.059114 secs)"],
[
"from ip ([x.x.x.x])\tby zm-as2 with ESMTP id fd672312-a36d-4bfe-8770-01b5cb3baca4 for nla2@archstl.org",
{"date", ~U"2017-08-08 12:05:31Z"}
],
+ ["from junghyuk@gbtp.or.kr with Spamsniper 2.96.32 (Processed in 1.059114 secs)"],
[
- "from freshdesk.com (ec2-x-x-x-x.compute-1.amazonaws.com [x.x.x.x])\tby x.sendgrid.net (SG) with ESMTP id eSJywaprRzabHWQplQP8xw\tfor ",
- {"date", ~U"2017-06-20 09:44:58Z"}
+ "from x.x.x.x\tby Spam Quarantine V01-06377SMG01.x.x.x (x.x.x.x) for ",
+ {"date", ~U"2016-04-15 17:22:55Z"}
],
- ["from trusted client by mx4.sika.com", {"date", ~U"2017-05-30 15:29:15Z"}],
- ["from local-ip[x.x.x.x] by FTGS", {"date", ~U"2014-12-28 18:04:31Z"}]
+ [
+ "from w.x.y.z ([1.1.1.1]) by x.y.local with InterScan Messaging Security Suite",
+ {"date", ~U"2019-11-25 11:00:46Z"}
+ ]
]
end
@@ -788,10 +804,8 @@ defmodule Mail.Parsers.RFC2822Test do
""")
assert message.headers["received"] == [
- [
- "from smtp.notes.na.collabserv.com (192.155.248.91)\tby d50lp03.ny.us.ibm.com (158.87.18.22) with IBM ESMTP SMTP Gateway: Authorized Use Only! Violators will be prosecuted (version=TLSv1/SSLv3 cipher=AES128-GCM-SHA256 bits=128/128)",
- {"date", ~U"2017-06-08 08:22:53Z"}
- ]
+ "from smtp.notes.na.collabserv.com (192.155.248.91)\tby d50lp03.ny.us.ibm.com (158.87.18.22) with IBM ESMTP SMTP Gateway: Authorized Use Only! Violators will be prosecuted (version=TLSv1/SSLv3 cipher=AES128-GCM-SHA256 bits=128/128)",
+ {"date", ~U"2017-06-08 08:22:53Z"}
]
end
@@ -883,26 +897,17 @@ defmodule Mail.Parsers.RFC2822Test do
message = parse_email(email)
assert [part1, part2, part3, part4] = message.parts
- assert %{headers: %{"content-type" => ["text/plain" | _]}} = part1
+ assert ["text/plain" | _] = Mail.Message.get_content_type(part1)
assert part1.body == "fran\xE7aise pr\xE8s \xE0 th\xE9\xE2tre lumi\xE8re"
- assert %{
- headers: %{
- "content-type" => ["application/octet-stream", {"name", "Imagin\xE9.pdf"}]
- }
- } = part2
+ assert Mail.Message.get_content_type(part2) ==
+ ["application/octet-stream", {"name", "Imagin\xE9.pdf"}]
- assert %{headers: %{"content-type" => ["application/pdf", {"name", "Pre\xECsentation.pdf"}]}} =
- part3
+ assert Mail.Message.get_content_type(part3) ==
+ ["application/pdf", {"name", "Pre\xECsentation.pdf"}]
- assert %{
- headers: %{
- "content-type" => [
- "application/octet-stream",
- {"name", "ID S\xE9 - Liste inscrits.xlsx"}
- ]
- }
- } = part4
+ assert Mail.Message.get_header!(part4, "content-type") ==
+ ["application/octet-stream", {"name", "ID S\xE9 - Liste inscrits.xlsx"}]
# This is a simple character replacement function that simulates charset change from Windows-1252/1258 to UTF-8
message =
@@ -926,26 +931,17 @@ defmodule Mail.Parsers.RFC2822Test do
)
assert [part1, part2, part3, part4] = message.parts
- assert %{headers: %{"content-type" => ["text/plain" | _]}} = part1
+ assert ["text/plain" | _] = Mail.Message.get_content_type(part1)
assert part1.body == "française près à théâtre lumière"
- assert %{
- headers: %{
- "content-type" => ["application/octet-stream", {"name", "Imaginé.pdf"}]
- }
- } = part2
-
- assert %{headers: %{"content-type" => ["application/pdf", {"name", "Présentation.pdf"}]}} =
- part3
-
- assert %{
- headers: %{
- "content-type" => [
- "application/octet-stream",
- {"name", "ID Sé - Liste inscrits.xlsx"}
- ]
- }
- } = part4
+ assert Mail.Message.get_content_type(part2) ==
+ ["application/octet-stream", {"name", "Imaginé.pdf"}]
+
+ assert Mail.Message.get_content_type(part3) ==
+ ["application/pdf", {"name", "Présentation.pdf"}]
+
+ assert Mail.Message.get_content_type(part4) ==
+ ["application/octet-stream", {"name", "ID Sé - Liste inscrits.xlsx"}]
end
test "content-type mixed with no body" do
diff --git a/test/mail/renderers/rfc_2822_test.exs b/test/mail/renderers/rfc_2822_test.exs
index 4ba42b5d..ecb206ab 100644
--- a/test/mail/renderers/rfc_2822_test.exs
+++ b/test/mail/renderers/rfc_2822_test.exs
@@ -162,17 +162,22 @@ defmodule Mail.Renderers.RFC2822Test do
end
test "headers - renders all headers" do
- headers = Mail.Renderers.RFC2822.render_headers(%{"foo" => "bar", "baz" => "qux"})
+ headers =
+ Mail.Headers.new()
+ |> Mail.Headers.append("foo", "bar")
+ |> Mail.Headers.append("baz", "qux")
+ |> Mail.Renderers.RFC2822.render_headers()
+
assert headers == "Foo: bar\r\nBaz: qux"
end
test "headers - handles empty headers as nil" do
headers =
- Mail.Renderers.RFC2822.render_headers(%{
- "content-type" => "text/plain",
- "message-id" => nil,
- "content-disposition" => "attachment"
- })
+ Mail.Headers.new()
+ |> Mail.Headers.append("content-type", "text/plain")
+ |> Mail.Headers.append("message-id", nil)
+ |> Mail.Headers.append("content-disposition", "attachment")
+ |> Mail.Renderers.RFC2822.render_headers()
assert headers == "Content-Type: text/plain\r\nContent-Disposition: attachment"
end
@@ -183,11 +188,11 @@ defmodule Mail.Renderers.RFC2822Test do
test "headers - handles empty headers as empty list" do
headers =
- Mail.Renderers.RFC2822.render_headers(%{
- "content-type" => "text/plain",
- "to" => [],
- "content-disposition" => "attachment"
- })
+ Mail.Headers.new()
+ |> Mail.Headers.append("content-type", "text/plain")
+ |> Mail.Headers.append("to", [])
+ |> Mail.Headers.append("content-disposition", "attachment")
+ |> Mail.Renderers.RFC2822.render_headers()
assert headers == "Content-Type: text/plain\r\nContent-Disposition: attachment"
end
@@ -198,11 +203,11 @@ defmodule Mail.Renderers.RFC2822Test do
test "headers - handles empty headers as blank string" do
headers =
- Mail.Renderers.RFC2822.render_headers(%{
- "content-type" => "text/plain",
- "from" => " ",
- "content-disposition" => "attachment"
- })
+ Mail.Headers.new()
+ |> Mail.Headers.append("content-type", "text/plain")
+ |> Mail.Headers.append("from", " ")
+ |> Mail.Headers.append("content-disposition", "attachment")
+ |> Mail.Renderers.RFC2822.render_headers()
assert headers == "Content-Type: text/plain\r\nContent-Disposition: attachment"
end
@@ -214,7 +219,10 @@ defmodule Mail.Renderers.RFC2822Test do
test "headers - blacklist certain headers" do
headers =
- Mail.Renderers.RFC2822.render_headers(%{"foo" => "bar", "baz" => "qux"}, ["foo", "baz"])
+ Mail.Headers.new()
+ |> Mail.Headers.append("foo", "bar")
+ |> Mail.Headers.append("baz", "qux")
+ |> Mail.Renderers.RFC2822.render_headers(["foo", "baz"])
assert headers == ""
end
@@ -279,6 +287,26 @@ defmodule Mail.Renderers.RFC2822Test do
assert_rfc2822_equal(result, fixture)
end
+ test "multipart render preserves duplicate Received after reorganize" do
+ message =
+ Mail.build_multipart()
+ |> Mail.put_subject("Dup received")
+ |> Mail.put_text("body")
+ |> Mail.Message.prepend_header(:received, "by mail.example.com; Mon, 11 May 2026 10:00:00 +0000")
+ |> Mail.Message.prepend_header(:received, "by relay.example.com; Mon, 11 May 2026 09:00:00 +0000")
+
+ rendered = Mail.Renderers.RFC2822.render(message)
+ parsed = Mail.Parsers.RFC2822.parse(rendered)
+ received = Mail.Message.get_header(parsed, "received")
+
+ assert length(received) == 2
+
+ assert Enum.map(received, &hd/1) == [
+ "by relay.example.com",
+ "by mail.example.com"
+ ]
+ end
+
test "renders a multipart mail with jpeg attachment" do
message =
Mail.build_multipart()
@@ -319,7 +347,7 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- refute Map.has_key?(message.headers, "bcc")
+ refute Mail.Message.has_header?(message, "bcc")
end
test "properly encodes body based upon Content-Transfer-Encoding value" do
@@ -446,17 +474,17 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{"content-type" => ["multipart/alternative", {"boundary", _boundary}]},
- parts: [
- %Mail.Message{
- headers: %{"content-type" => ["text/plain", {"charset", "UTF-8"}]},
- body: "Some text",
- parts: [],
- multipart: false
- }
- ]
- } = message
+ assert ["multipart/alternative", {"boundary", _boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [part] = message.parts
+
+ assert Mail.Message.get_content_type(part) == [
+ "text/plain",
+ {"charset", "UTF-8"}
+ ]
+
+ assert part.body == "Some text"
end
test "multipart/alternative with text/plain and text/html" do
@@ -470,23 +498,24 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{"content-type" => ["multipart/alternative", {"boundary", _boundary}]},
- 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: "Some HTML
",
- parts: [],
- multipart: false
- }
- ]
- } = message
+ assert ["multipart/alternative", {"boundary", _boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [text_part, html_part] = message.parts
+
+ assert Mail.Message.get_content_type(text_part) == [
+ "text/plain",
+ {"charset", "UTF-8"}
+ ]
+
+ assert text_part.body == "Some text"
+
+ assert Mail.Message.get_content_type(html_part) == [
+ "text/html",
+ {"charset", "UTF-8"}
+ ]
+
+ assert html_part.body == "Some HTML
"
end
test "multipart/related with text/html and inline attachment" do
@@ -507,23 +536,31 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{"content-type" => ["multipart/related", {"boundary", _boundary}]},
- parts: [
- %Mail.Message{
- headers: %{"content-type" => ["text/html", {"charset", "UTF-8"}]}
- },
- %Mail.Message{
- headers: %{
- "content-transfer-encoding" => "base64",
- "content-type" => ["image/jpeg", {"charset", "us-ascii"}],
- "content-id" => "c_id",
- "content-disposition" => ["inline", {"filename", "image.jpg"}],
- "x-attachment-id" => "a_id"
- }
- }
- ]
- } = message
+ assert ["multipart/related", {"boundary", _boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [html_part, attachment_part] = message.parts
+
+ assert Mail.Message.get_content_type(html_part) == [
+ "text/html",
+ {"charset", "UTF-8"}
+ ]
+
+ assert Mail.Message.get_header!(attachment_part, "content-transfer-encoding") == "base64"
+
+ assert Mail.Message.get_content_type(attachment_part) == [
+ "image/jpeg",
+ {"charset", "us-ascii"}
+ ]
+
+ assert Mail.Message.get_header!(attachment_part, "content-id") == "c_id"
+
+ assert Mail.Message.get_header!(attachment_part, "content-disposition") == [
+ "inline",
+ {"filename", "image.jpg"}
+ ]
+
+ assert Mail.Message.get_header!(attachment_part, "x-attachment-id") == "a_id"
end
test "multipart/mixed with text/html and attachment" do
@@ -537,21 +574,27 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{"content-type" => ["multipart/mixed", {"boundary", _boundary}]},
- parts: [
- %Mail.Message{
- headers: %{"content-type" => ["text/html", {"charset", "UTF-8"}]}
- },
- %Mail.Message{
- headers: %{
- "content-transfer-encoding" => "base64",
- "content-type" => ["image/jpeg", {"charset", "us-ascii"}],
- "content-disposition" => ["attachment", {"filename", "image.jpg"}]
- }
- }
- ]
- } = message
+ assert ["multipart/mixed", {"boundary", _boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [html_part, attachment_part] = message.parts
+
+ assert Mail.Message.get_content_type(html_part) == [
+ "text/html",
+ {"charset", "UTF-8"}
+ ]
+
+ assert Mail.Message.get_header!(attachment_part, "content-transfer-encoding") == "base64"
+
+ assert Mail.Message.get_content_type(attachment_part) == [
+ "image/jpeg",
+ {"charset", "us-ascii"}
+ ]
+
+ assert Mail.Message.get_header!(attachment_part, "content-disposition") == [
+ "attachment",
+ {"filename", "image.jpg"}
+ ]
end
test "multipart/mixed with multipart/alternative and attachment" do
@@ -566,41 +609,41 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{"content-type" => ["multipart/mixed", {"boundary", _mixed_boundary}]},
- parts: [
- %Mail.Message{
- headers: %{
- "content-type" => [
- "multipart/alternative",
- {"boundary", _alternative_boundary}
- ]
- },
- 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: "Some HTML
",
- parts: [],
- multipart: false
- }
- ]
- },
- %Mail.Message{
- headers: %{
- "content-transfer-encoding" => "base64",
- "content-type" => ["image/jpeg", {"charset", "us-ascii"}]
- },
- parts: [],
- multipart: false
- }
- ]
- } = message
+ assert ["multipart/mixed", {"boundary", _mixed_boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [alternative_part, attachment_part] = message.parts
+
+ assert ["multipart/alternative", {"boundary", _alternative_boundary}] =
+ Mail.Message.get_content_type(alternative_part)
+
+ assert [text_part, html_part] = alternative_part.parts
+
+ assert Mail.Message.get_content_type(text_part) == [
+ "text/plain",
+ {"charset", "UTF-8"}
+ ]
+
+ assert text_part.body == "Some text"
+
+ assert Mail.Message.get_content_type(html_part) == [
+ "text/html",
+ {"charset", "UTF-8"}
+ ]
+
+ assert html_part.body == "Some HTML
"
+
+ assert Mail.Message.get_header!(attachment_part, "content-transfer-encoding") == "base64"
+
+ assert Mail.Message.get_content_type(attachment_part) == [
+ "image/jpeg",
+ {"charset", "us-ascii"}
+ ]
+
+ assert Mail.Message.get_header!(attachment_part, "content-disposition") == [
+ "attachment",
+ {"filename", "tiny_jpeg.jpg"}
+ ]
end
test "multipart/related and inline attachment and multipart/alternative with text/plain and text/html" do
@@ -622,46 +665,46 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{
- "content-type" => ["multipart/related", {"boundary", _related_boundary}]
- },
- parts: [
- %Mail.Message{
- headers: %{
- "content-type" => [
- "multipart/alternative",
- {"boundary", _alternative_boundary}
- ]
- },
- 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: "Some HTML
",
- parts: [],
- multipart: false
- }
- ]
- },
- %Mail.Message{
- headers: %{
- "content-transfer-encoding" => "base64",
- "content-type" => ["image/jpeg", {"charset", "us-ascii"}],
- "content-id" => "c_id",
- "content-disposition" => ["inline", {"filename", "image.jpg"}],
- "x-attachment-id" => "a_id"
- },
- parts: [],
- multipart: false
- }
- ]
- } = message
+ assert ["multipart/related", {"boundary", _related_boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [alternative_part, inline_attachment_part] = message.parts
+
+ assert ["multipart/alternative", {"boundary", _alternative_boundary}] =
+ Mail.Message.get_content_type(alternative_part)
+
+ assert [text_part, html_part] = alternative_part.parts
+
+ assert Mail.Message.get_content_type(text_part) == [
+ "text/plain",
+ {"charset", "UTF-8"}
+ ]
+
+ assert text_part.body == "Some text"
+
+ assert Mail.Message.get_content_type(html_part) == [
+ "text/html",
+ {"charset", "UTF-8"}
+ ]
+
+ assert html_part.body == "Some HTML
"
+
+ assert Mail.Message.get_header!(inline_attachment_part, "content-transfer-encoding") ==
+ "base64"
+
+ assert Mail.Message.get_content_type(inline_attachment_part) == [
+ "image/jpeg",
+ {"charset", "us-ascii"}
+ ]
+
+ assert Mail.Message.get_header!(inline_attachment_part, "content-id") == "c_id"
+
+ assert Mail.Message.get_header!(inline_attachment_part, "content-disposition") == [
+ "inline",
+ {"filename", "image.jpg"}
+ ]
+
+ assert Mail.Message.get_header!(inline_attachment_part, "x-attachment-id") == "a_id"
end
test "multipart/mixed with attachment and multipart/alternative with text/plain and text/html" do
@@ -676,44 +719,41 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{
- "content-type" => ["multipart/mixed", {"boundary", _mixed_boundary}]
- },
- parts: [
- %Mail.Message{
- headers: %{
- "content-type" => [
- "multipart/alternative",
- {"boundary", _alternative_boundary}
- ]
- },
- 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: "Some HTML
",
- parts: [],
- multipart: false
- }
- ]
- },
- %Mail.Message{
- headers: %{
- "content-transfer-encoding" => "base64",
- "content-type" => ["image/jpeg", {"charset", "us-ascii"}],
- "content-disposition" => ["attachment", {"filename", "image.jpg"}]
- },
- parts: [],
- multipart: false
- }
- ]
- } = message
+ assert ["multipart/mixed", {"boundary", _mixed_boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [alternative_part, attachment_part] = message.parts
+
+ assert ["multipart/alternative", {"boundary", _alternative_boundary}] =
+ Mail.Message.get_content_type(alternative_part)
+
+ assert [text_part, html_part] = alternative_part.parts
+
+ assert Mail.Message.get_content_type(text_part) == [
+ "text/plain",
+ {"charset", "UTF-8"}
+ ]
+
+ assert text_part.body == "Some text"
+
+ assert Mail.Message.get_content_type(html_part) == [
+ "text/html",
+ {"charset", "UTF-8"}
+ ]
+
+ assert html_part.body == "Some HTML
"
+
+ assert Mail.Message.get_header!(attachment_part, "content-transfer-encoding") == "base64"
+
+ assert Mail.Message.get_content_type(attachment_part) == [
+ "image/jpeg",
+ {"charset", "us-ascii"}
+ ]
+
+ assert Mail.Message.get_header!(attachment_part, "content-disposition") == [
+ "attachment",
+ {"filename", "image.jpg"}
+ ]
end
test "multipart/mixed with multipart/related and inline attachment and multipart/alternative with text/plain and text/html" do
@@ -736,59 +776,58 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{"content-type" => ["multipart/mixed", {"boundary", _mixed_boundary}]},
- parts: [
- %Mail.Message{
- headers: %{
- "content-type" => ["multipart/related", {"boundary", _related_boundary}]
- },
- parts: [
- %Mail.Message{
- headers: %{
- "content-type" => [
- "multipart/alternative",
- {"boundary", _alternative_boundary}
- ]
- },
- 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: "Some HTML
",
- parts: [],
- multipart: false
- }
- ]
- },
- %Mail.Message{
- headers: %{
- "content-transfer-encoding" => "base64",
- "content-type" => ["image/jpeg", {"charset", "us-ascii"}],
- "content-id" => "c_id",
- "content-disposition" => ["inline", {"filename", "image.jpg"}],
- "x-attachment-id" => "a_id"
- },
- parts: [],
- multipart: false
- }
- ]
- },
- %Mail.Message{
- headers: %{
- "content-transfer-encoding" => "base64",
- "content-type" => ["image/jpeg", {"charset", "us-ascii"}]
- },
- parts: [],
- multipart: false
- }
- ]
- } = message
+ assert ["multipart/mixed", {"boundary", _mixed_boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [related_part, attachment_part] = message.parts
+
+ assert ["multipart/related", {"boundary", _related_boundary}] =
+ Mail.Message.get_content_type(related_part)
+
+ assert [alternative_part, inline_attachment_part] = related_part.parts
+
+ assert ["multipart/alternative", {"boundary", _alternative_boundary}] =
+ Mail.Message.get_content_type(alternative_part)
+
+ assert [text_part, html_part] = alternative_part.parts
+
+ assert Mail.Message.get_content_type(text_part) == [
+ "text/plain",
+ {"charset", "UTF-8"}
+ ]
+
+ assert text_part.body == "Some text"
+
+ assert Mail.Message.get_content_type(html_part) == [
+ "text/html",
+ {"charset", "UTF-8"}
+ ]
+
+ assert html_part.body == "Some HTML
"
+
+ assert Mail.Message.get_header!(inline_attachment_part, "content-transfer-encoding") ==
+ "base64"
+
+ assert Mail.Message.get_content_type(inline_attachment_part) == [
+ "image/jpeg",
+ {"charset", "us-ascii"}
+ ]
+
+ assert Mail.Message.get_header!(inline_attachment_part, "content-id") == "c_id"
+
+ assert Mail.Message.get_header!(inline_attachment_part, "content-disposition") == [
+ "inline",
+ {"filename", "image.jpg"}
+ ]
+
+ assert Mail.Message.get_header!(inline_attachment_part, "x-attachment-id") == "a_id"
+
+ assert Mail.Message.get_header!(attachment_part, "content-transfer-encoding") == "base64"
+
+ assert Mail.Message.get_content_type(attachment_part) == [
+ "image/jpeg",
+ {"charset", "us-ascii"}
+ ]
end
test "multipart/mixed with only attachments" do
@@ -801,19 +840,20 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{"content-type" => ["multipart/mixed", {"boundary", _boundary}]},
- parts: [
- %Mail.Message{
- headers: %{
- "content-type" => ["image/jpeg", {"charset", "us-ascii"}],
- "content-disposition" => ["attachment", {"filename", "image.jpg"}]
- },
- parts: [],
- multipart: false
- }
- ]
- } = message
+ assert ["multipart/mixed", {"boundary", _boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [attachment_part] = message.parts
+
+ assert Mail.Message.get_content_type(attachment_part) == [
+ "image/jpeg",
+ {"charset", "us-ascii"}
+ ]
+
+ assert Mail.Message.get_header!(attachment_part, "content-disposition") == [
+ "attachment",
+ {"filename", "image.jpg"}
+ ]
end
test "multipart/mixed with only inline attachments" do
@@ -833,19 +873,20 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %Mail.Message{
- headers: %{"content-type" => ["multipart/mixed", {"boundary", _boundary}]},
- parts: [
- %Mail.Message{
- headers: %{
- "content-type" => ["image/jpeg", {"charset", "us-ascii"}],
- "content-disposition" => ["inline", {"filename", "inline_jpeg.jpg"}]
- },
- parts: [],
- multipart: false
- }
- ]
- } = message
+ assert ["multipart/mixed", {"boundary", _boundary}] =
+ Mail.Message.get_content_type(message)
+
+ assert [inline_attachment_part] = message.parts
+
+ assert Mail.Message.get_content_type(inline_attachment_part) == [
+ "image/jpeg",
+ {"charset", "us-ascii"}
+ ]
+
+ assert Mail.Message.get_header!(inline_attachment_part, "content-disposition") == [
+ "inline",
+ {"filename", "inline_jpeg.jpg"}
+ ]
end
test "multipart/mixed with custom headers" do
@@ -869,7 +910,7 @@ defmodule Mail.Renderers.RFC2822Test do
|> Mail.Renderers.RFC2822.render()
|> Mail.Parsers.RFC2822.parse()
- assert %{"x-custom-header" => "custom value"} = message.headers
+ assert Mail.Message.get_header!(message, "x-custom-header") == "custom value"
end
end
end
diff --git a/test/mail_test.exs b/test/mail_test.exs
index b07fd475..f22c5456 100644
--- a/test/mail_test.exs
+++ b/test/mail_test.exs
@@ -1,10 +1,11 @@
defmodule MailTest do
use ExUnit.Case, async: true
+ import Mail.TestHelpers.Headers
doctest Mail
defmodule TestRenderer do
def render(message) do
- Mail.Message.get_header(message, "subject")
+ Mail.Message.get_header!(message, "subject")
end
end
@@ -356,12 +357,12 @@ defmodule MailTest do
assert Mail.Message.get_content_type(mail) == ["text/markdown"]
- assert Mail.Message.get_header(mail, :content_disposition) == [
+ assert Mail.Message.get_header!(mail, :content_disposition) == [
"attachment",
{"filename", "README.md"}
]
- assert Mail.Message.get_header(mail, :content_transfer_encoding) == :base64
+ assert Mail.Message.get_header!(mail, :content_transfer_encoding) == :base64
assert mail.body == file_content
end
@@ -374,12 +375,12 @@ defmodule MailTest do
assert Mail.Message.get_content_type(mail) == ["text/markdown"]
- assert Mail.Message.get_header(mail, :content_disposition) == [
+ assert Mail.Message.get_header!(mail, :content_disposition) == [
"attachment",
{"filename", "DOESNOTEXIST.md"}
]
- assert Mail.Message.get_header(mail, :content_transfer_encoding) == :base64
+ assert Mail.Message.get_header!(mail, :content_transfer_encoding) == :base64
assert mail.body == file_content
end
@@ -393,12 +394,12 @@ defmodule MailTest do
assert Mail.Message.get_content_type(part) == ["text/markdown"]
- assert Mail.Message.get_header(part, :content_disposition) == [
+ assert Mail.Message.get_header!(part, :content_disposition) == [
"attachment",
{"filename", "README.md"}
]
- assert Mail.Message.get_header(part, :content_transfer_encoding) == :base64
+ assert Mail.Message.get_header!(part, :content_transfer_encoding) == :base64
assert part.body == file_content
end
@@ -424,12 +425,12 @@ defmodule MailTest do
assert Mail.Message.get_content_type(part) == ["text/markdown"]
- assert Mail.Message.get_header(part, :content_disposition) == [
+ assert Mail.Message.get_header!(part, :content_disposition) == [
"attachment",
{"filename", "DOESNOTEXIST.md"}
]
- assert Mail.Message.get_header(part, :content_transfer_encoding) == :base64
+ assert Mail.Message.get_header!(part, :content_transfer_encoding) == :base64
assert part.body == file_content
end
@@ -555,49 +556,49 @@ defmodule MailTest do
parts: [
%Mail.Message{
body: "file content 1",
- headers: %{
+ headers: headers_from_map(%{
"content-disposition" => ["attachment"],
"content-transfer-encoding" => :base64
- }
+ })
},
%Mail.Message{
body: "file content 2",
- headers: %{
+ headers: headers_from_map(%{
"content-disposition" => ["attachment", {"filename", "README.md"}],
"content-transfer-encoding" => :base64
- }
+ })
},
%Mail.Message{
body: "file content 3",
- headers: %{
+ headers: headers_from_map(%{
"content-disposition" => ["attachment"],
"content-type" => ["application/octet-stream", {"name", "README.md"}]
- }
+ })
},
%Mail.Message{
body: "file content 4",
- headers: %{
+ headers: headers_from_map(%{
"content-disposition" => ["attachment", {"foo", "bar"}, {"filename", "README.md"}],
"content-type" => ["application/octet-stream", {"name", "README.md"}]
- }
+ })
},
%Mail.Message{
body: "file content 5",
- headers: %{
+ headers: headers_from_map(%{
"content-disposition" => ["attachment"],
"content-type" => [
"application/octet-stream",
{"foo", "bar"},
{"name", "README.md"}
]
- }
+ })
},
%Mail.Message{
body: "file content 6",
- headers: %{
+ headers: headers_from_map(%{
"content-disposition" => ["attachment", {"foo", "bar"}],
"content-type" => ["application/octet-stream", {"foo", "bar"}]
- }
+ })
}
]
}
diff --git a/test/support/headers_helpers.ex b/test/support/headers_helpers.ex
new file mode 100644
index 00000000..e284463a
--- /dev/null
+++ b/test/support/headers_helpers.ex
@@ -0,0 +1,19 @@
+defmodule Mail.TestHelpers.Headers do
+ @moduledoc false
+
+ @doc """
+ Builds a `%Mail.Headers{}` from a map.
+
+ This is a **lossy** helper intended for tests:
+ - maps cannot represent duplicate headers
+ - map iteration order is not meaningful for header ordering
+ """
+ def headers_from_map(map) when is_map(map) do
+ map
+ |> Enum.sort_by(fn {k, _v} -> to_string(k) end)
+ |> Enum.reduce(Mail.Headers.new(), fn {k, v}, acc ->
+ Mail.Headers.append(acc, k, v)
+ end)
+ end
+end
+