lib/bamboo/adapters/message/encoding.ex

defmodule BambooSes.Encoding do
  @moduledoc """
  Encoding module contains various helper methods related to encoding of a strings and an email addresses
  """

  @rfc1342_maximum_encoded_word_length 75

  @doc """
  Encodes an email address.

  Returns encoded email address.

  ## Example
      prepare_address({"", "john.doe@example.com"})
      prepare_address({"John Doe", "john.doe@example.com"})
  """
  @spec prepare_address(Bamboo.Email.address()) :: String.t()
  def prepare_address({nil, address}), do: encode_address(address)
  def prepare_address({"", address}), do: encode_address(address)

  def prepare_address({name, address}) do
    "\"#{maybe_rfc1342_encode(name)}\" <#{encode_address(address)}>"
  end

  @doc """
  Encodes string to rfc1342 if needed

  Returns encoded string
  """
  @spec maybe_rfc1342_encode(String.t()) :: String.t()
  def maybe_rfc1342_encode(string) when is_binary(string) do
    should_encode? = not ascii?(string) || String.contains?(string, ["\"", "?", "\\"])

    if should_encode? do
      rfc1342_encode(string)
    else
      string
    end
  end

  def maybe_rfc1342_encode(_), do: ""

  @doc """
  Checks if string contains only ASCII characters

  Returns boolean value
  """
  @spec ascii?(String.t()) :: boolean()
  def ascii?(string) do
    string
    |> String.to_charlist()
    |> Enum.all?(&(&1 < 127))
  end

  defp encode_address(address) do
    [local_part, domain_part] = String.split(address, "@")
    Enum.join([local_part, :idna.utf8_to_ascii(domain_part)], "@")
  end

  defp rfc1342_encode(string) when is_binary(string), do: rfc1342_encode(string, [])

  defp rfc1342_encode("", acc), do: acc |> Enum.reverse() |> Enum.join(" ")

  defp rfc1342_encode(string, acc) do
    # https://tools.ietf.org/html/rfc1342
    # > An encoded-word may not be more than 75 characters long, including
    # > charset, encoding, encoded-text, and delimiters.  If it is desirable
    # > to encode more text than will fit in an encoded-word of 75
    # > characters, multiple encoded-words (separated by SPACE or newline)
    # > may be used.
    maximum_possible_text_length =
      @rfc1342_maximum_encoded_word_length - String.length(encode_word(""))

    {encoded, rest} =
      maximum_possible_text_length..1
      |> Enum.reduce_while(nil, fn n, _ ->
        {word, rest} = String.split_at(string, n)
        encoded = encode_word(word)

        if String.length(encoded) <= @rfc1342_maximum_encoded_word_length do
          {:halt, {encoded, rest}}
        else
          {:cont, nil}
        end
      end)

    rfc1342_encode(rest, [encoded | acc])
  end

  defp encode_word(word) do
    "=?utf-8?B?#{Base.encode64(word)}?="
  end
end