lib/mail/message.ex

defmodule Mail.Message do
  defstruct headers: %{},
            body: nil,
            parts: [],
            multipart: false

  @type t :: %__MODULE__{}

  @doc """
  Add new part

      Mail.Message.put_part(%Mail.Message{}, %Mail.Message{})
  """
  def put_part(message, %Mail.Message{} = part) do
    put_in(message.parts, message.parts ++ [part])
  end

  @doc """
  Delete a matching part

  Will delete a matching part in the `parts` list. If the part
  is not found no error is raised.
  """
  def delete_part(message, part),
    do: put_in(message.parts, List.delete(message.parts, part))

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

      Mail.Message.match_content_type?(message, ~r/text/)
      true

      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()

    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")

  @doc """
  Add a new header key/value pair

      Mail.Message.put_header(%Mail.Message{}, :content_type, "text/plain")

  The individual headers will be in the `headers` field on the
  `%Mail.Message{}` struct
  """
  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 get_header(message, key) when not is_binary(key),
    do: get_header(message, to_string(key))

  def get_header(message, key),
    do: Map.get(message.headers, fix_header(key))

  @doc """
  Deletes a specific header key

      Mail.Message.delete_header(%Mail.Message{headers: %{foo: "bar"}}, :foo)
      %Mail.Message{headers: %{}}
  """
  def delete_header(message, header),
    do: %{message | headers: Map.delete(message.headers, fix_header(header))}

  @doc """
  Deletes a list of headers

      Mail.Message.delete_headers(%Mail.Message{headers: %{foo: "bar", baz: "qux"}}, [:foo, :baz])
      %Mail.Message{headers: %{}}
  """
  def delete_headers(message, headers)
  def delete_headers(message, []), do: message

  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))

  defp fix_header(key) when not is_binary(key),
    do: fix_header(to_string(key))

  defp fix_header(key),
    do: key |> String.downcase() |> String.replace("_", "-")

  @doc """
  Add a new `content-type` header

  The value will always be wrapped in a `List`

      Mail.Message.put_content_type(%Mail.Message{}, "text/plain")
      %Mail.Message{headers: %{content_type: ["text/plain"]}}
  """
  def put_content_type(message, content_type),
    do: put_header(message, :content_type, content_type)

  @doc """
  Gets the `content_type` from the header

  Will ensure the `content_type` is always wrapped in a `List`

      Mail.Message.get_content_type(%Mail.Message{})
      [""]

      Mail.Message.get_content_type(%Mail.Message{content_type: "text/plain"})
      ["text/plain"]

      Mail.Message.get_content_type(%Mail.Message{headers: %{content_type: ["multipart/mixed", {"boundary", "foobar"}]}})
      ["multipart/mixed", {"boundary", "foobar"}]
  """
  def get_content_type(message),
    do:
      (get_header(message, :content_type) || "")
      |> List.wrap()

  @doc """
  Adds a boundary value to the `content_type` header

  Will overwrite existing `boundary` key in the list. Will preserve other
  values in the list

      Mail.Message.put_boundary(%Mail.Message{}, "foobar")
      %Mail.Message{headers: %{content_type: ["", {"boundary", "foobar"}]}}

      Mail.Message.put_boundary(%Mail.Message{headers: %{content_type: ["multipart/mixed", {"boundary", "bazqux"}]}})
      %Mail.Message{headers: %{content_type: ["multipart/mixed", {"boundary", "foobar"}]}}
  """
  def put_boundary(message, boundary) do
    content_type =
      get_content_type(message)
      |> Mail.Proplist.put("boundary", boundary)

    put_content_type(message, content_type)
  end

  @doc """
  Gets the boundary value from the `content_type` header

  Will retrieve the boundary value. If one is not set a random one is generated.

      Mail.Message.get_boundary(%Mail.Message{headers: %{content_type: ["multipart/mixed", {"boundary", "foobar"}]}})
      "foobar"

      Mail.Message.get_boundary(%Mail.Message{headers: %{content_type: ["multipart/mixed"]}})
      "ASDFSHNEW3473423"
  """
  def get_boundary(message) do
    message
    |> get_content_type()
    |> Mail.Proplist.get("boundary")
    |> case do
      nil -> generate_boundary()
      boundary -> boundary
    end
  end

  defp generate_boundary do
    :crypto.strong_rand_bytes(12) |> Base.encode16()
  end

  @doc """
  Sets the `body` field on the part

      Mail.Message.put_body(%Mail.Message{}, "Some data")
      %Mail.Message{body: "Some Data", headers: %{}}
  """
  def put_body(part, body),
    do: put_in(part.body, body)

  @doc """
  Build a new text message

      Mail.Message.build_text("Some text")
      %Mail.Message{body: "Some text", headers: %{content_type: "text/plain"}}

      Mail.Message.build_text("Some text", charset: "UTF-8")
      %Mail.Message{body: "Some text", headers: %{content_type: ["text/plain", {"charset", "UTF-8"}]}}

  ## Options

  * `:charset` - The character encoding standard for content type
  """
  def build_text(body, opts \\ []) do
    content_type =
      case opts do
        charset: charset ->
          ["text/plain", {"charset", charset}]

        _else ->
          "text/plain"
      end

    put_content_type(%Mail.Message{}, content_type)
    |> put_header(:content_transfer_encoding, :quoted_printable)
    |> put_body(body)
  end

  @doc """
  Build a new HTML message

      Mail.Message.build_html("<h1>Some HTML</h1>")
      %Mail.Message{body: "<h1>Some HTML</h1>", headers: %{content_type: "text/html"}}

      Mail.Message.build_html("<h1>Some HTML</h1>", charset: "UTF-8")
      %Mail.Message{body: "<h1>Some HTML</h1>", headers: %{content_type: ["text/html", {"charset", "UTF-8"}]}}

  ## Options

  * `:charset` - The character encoding standard for content type
  """
  def build_html(body, opts \\ []) do
    content_type =
      case opts do
        charset: charset ->
          ["text/html", {"charset", charset}]

        _else ->
          "text/html"
      end

    put_content_type(%Mail.Message{}, content_type)
    |> put_header(:content_transfer_encoding, :quoted_printable)
    |> put_body(body)
  end

  @doc """
  Add attachment meta data to a `Mail.Message`

  Will allow you to create a new part that is meant to be used as an
  attachment.

  You can pass either a filepath or a tuple as the second argument. If a
  tuple is being passed the tuple must only have two elements:
  `{filename, filedata}`.

  The mimetype of the file is determined by the file extension.

      Mail.Message.build_attachment("README.md")
      %Mail.Message{data: "base64 encoded", headers: %{content_type: ["text/x-markdown"], content_disposition: ["attachment", filename: "README.md"], content_transfer_encoding: :base64}}

      Mail.Message.build_attachment({"README.md", "file contents"})
      %Mail.Message{data: "base64 encoded", headers: %{content_type: ["text/x-markdown"], content_disposition: ["attachment", filename: "README.md"], content_transfer_encoding: :base64}}

  ## Options

  See `put_attachment/3` for options

  ## Custom mimetype library

  By default `Mail` will use its own internal mimetype adapter. However,
  you may want to rely on `Plug` and the custom mimetypes that you have
  created for it. You can override the mimetype function in the
  `config.exs` of your application:

      config :mail, mimetype_fn: &CustomMimeAdapter.type/1

  This function should take a string that is the file extension. It
  should return a single mimetype.

      CustomMimeAdapter.type("md")
      "text/markdown"
  """
  def build_attachment(path_or_file_tuple, opts \\ [])

  def build_attachment(path, opts) when is_binary(path),
    do: put_attachment(%Mail.Message{}, path, opts)

  def build_attachment(file, opts) when is_tuple(file),
    do: put_attachment(%Mail.Message{}, file, opts)

  @doc """
  Adds a new attachment part to the provided message

  The first argument must be a `Mail.Message`. The remaining argument is described in `build_attachment/1`

  ## Options
    * `:headers` - Headers to be merged

  ## Examples
      Mail.Message.put_attachment(%Mail.Message{}, "README.md")
      %Mail.Message{data: "base64 encoded", headers: %{content_type: ["text/x-markdown"], content_disposition: ["attachment", filename: "README.md"], content_transfer_encoding: :base64}}

      Mail.Message.put_attachment(%Mail.Message{}, {"README.md", "file contents"})
      %Mail.Message{data: "base64 encoded", headers: %{content_type: ["text/x-markdown"], content_disposition: ["attachment", filename: "README.md"], content_transfer_encoding: :base64}}

  ### Adding custom headers
      Mail.Message.put_attachment(%Mail.Message{}, "README.md", headers: [content_id: "attachment-id"])
      %Mail.Message{data: "base64 encoded", headers: %{content_type: ["text/x-markdown"], content_disposition: ["attachment", filename: "README.md"], content_transfer_encoding: :base64, content_id: "attachment-id"}}

      Mail.Message.put_attachment(%Mail.Message{}, {"README.md", data}, headers: [content_id: "attachment-id"])
      %Mail.Message{data: "base64 encoded", headers: %{content_type: ["text/x-markdown"], content_disposition: ["attachment", filename: "README.md"], content_transfer_encoding: :base64, content_id: "attachment-id"}}
  """
  def put_attachment(message, path_or_file_tuple, opts \\ [])

  def put_attachment(%Mail.Message{} = message, path, opts) when is_binary(path) do
    {:ok, data} = File.read(path)
    basename = Path.basename(path)
    put_attachment(message, {basename, data}, opts)
  end

  def put_attachment(%Mail.Message{} = message, {filename, data}, opts) do
    filename = Path.basename(filename)

    message
    |> put_body(data)
    |> put_content_type(mimetype(filename))
    |> put_header(:content_disposition, ["attachment", {"filename", filename}])
    |> put_header(:content_transfer_encoding, :base64)
    |> merge_headers(opts)
  end

  defp merge_headers(message, opts) do
    Enum.reduce(opts[:headers] || [], message, fn {k, v}, acc -> put_header(acc, k, v) end)
  end

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

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

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

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

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

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

  Can be a message with a `content_type` of `text/plain` or `text/html`

  Returns `Boolean`
  """
  def is_text_part?(message) do
    match_content_type?(message, ~r/text\/(plain|html)/)
  end

  @doc """
  Determines the message has any text (`text/plain` or `text/html`) parts

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

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

  defp has_part?(parts, fun),
    do: Enum.any?(parts, &fun.(&1))

  defp mimetype(filename) do
    mimetype_fn = Application.get_env(:mail, :mimetype_fn) || (&Mail.MIME.type/1)

    extension =
      Path.extname(filename)
      |> String.split(".")
      |> List.last()

    mimetype_fn.(extension)
  end
end