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 +