lib/yugo/filter.ex

defmodule Yugo.Filter do
  @moduledoc """
  A construct that enables you to specify which emails you would
  like a [`Client`](`Yugo.Client`) to notify you about.

  ## Example

  To create a filter that only accepts emails that have been read and not replied to, and whose subject contains "Order Information":

      alias Yugo.Filter

      my_filter =
        Filter.all()
        |> Filter.has_flag(:seen)
        |> Filter.lacks_flag(:answered)
        |> Filter.subject_matches(~r/Order Information/)
  """

  # :recent is purposely omitted because it is too low-level
  @legal_flag_atoms [:seen, :answered, :flagged, :draft, :deleted]

  @type t :: %__MODULE__{
          has_flags: [Yugo.flag()],
          lacks_flags: [Yugo.flag()],
          subject_regex: nil | Regex.t(),
          sender_regex: nil | Regex.t()
        }

  defstruct has_flags: [],
            lacks_flags: [],
            subject_regex: nil,
            sender_regex: nil

  @doc false
  def needs_envelope?(%__MODULE__{} = filter) do
    filter.subject_regex != nil || filter.sender_regex != nil
  end

  @doc false
  def needs_flags?(%__MODULE__{} = filter) do
    filter.has_flags != [] || filter.lacks_flags != []
  end

  @doc false
  def accepts?(%__MODULE__{} = filter, message) do
    Enum.all?([
      !filter.subject_regex ||
        (message.envelope.subject != nil &&
           Regex.match?(filter.subject_regex, message.envelope.subject)),
      !filter.sender_regex ||
        Enum.any?(message.envelope.sender, &Regex.match?(filter.sender_regex, &1)),
      filter.has_flags == [] || Enum.all?(filter.has_flags, &Enum.member?(message.flags, &1)),
      filter.lacks_flags == [] ||
        Enum.all?(filter.lacks_flags, &(!Enum.member?(message.flags, &1)))
    ])
  end

  @doc """
  Returns a [`Filter`](`Yugo.Filter`) that accepts all emails.
  """
  @spec all() :: __MODULE__.t()
  def all(), do: %__MODULE__{}

  @doc """
  Only accepts emails that have the specified flag.

  "Flags" are tags that are associated with an email message.

  ## Flags

  IMAP defines several flags that can be set by clients. Possible flags are:

    * `:seen` - Message has been read.

    * `:answered` - Message has been answered.

    * `:flagged` - Message is "flagged" for urgent/special attention.

    * `:draft` - Message has not completed composition (marked as a draft).

    * `:deleted` - Message is "deleted". In most email clients, this means it was moved to the trash folder.

  ## Example

      alias Filter

      # build a filter that only allows messages that have been seen.
      Filter.all()
      |> Filter.has_flag(:seen)
  """
  @spec has_flag(__MODULE__.t(), Yugo.flag()) :: __MODULE__.t()
  def has_flag(%__MODULE__{} = filter, flag) when flag in @legal_flag_atoms do
    flag not in filter.lacks_flags ||
      raise "Cannot enforce a has_flag constraint for \"#{inspect(flag)}\" because this filter already has a lacks_flag constraint for the same flag."

    if flag in filter.has_flags do
      filter
    else
      %{filter | has_flags: [flag | filter.has_flags]}
    end
  end

  @doc """
  Only accepts emails that do not have the specified flag.
  See `has_flag/2` for more information about flags.

  ## Example

      alias Filter

      # build a filter that allows all messages that do not have the :deleted flag
      Filter.all()
      |> Filter.lacks_flag(:deleted)
  """
  @spec lacks_flag(__MODULE__.t(), Yugo.flag()) :: __MODULE__.t()
  def lacks_flag(%__MODULE__{} = filter, flag) when flag in @legal_flag_atoms do
    flag not in filter.has_flags ||
      raise "Cannot enforce a lacks_flag constraint for \"#{inspect(flag)}\" because this filter already has a has_flag constraint for the same flag."

    if flag in filter.lacks_flags do
      filter
    else
      %{filter | lacks_flags: [flag | filter.lacks_flags]}
    end
  end

  @doc """
  Accepts emails whose "subject" line matches the given `Regex`.
  """
  @spec subject_matches(__MODULE__.t(), Regex.t()) :: __MODULE__.t()
  def subject_matches(%__MODULE__{} = filter, pattern) when is_struct(pattern, Regex) do
    filter.subject_regex == nil ||
      raise "This filter already has a subject match constraint. Filters can only have one of these constraints - to match multiple things, use regex OR patterns."

    %{filter | subject_regex: pattern}
  end

  @doc """
  Accepts emails where the email address of the sender matches the given `Regex`.

  ## Example


      alias Yugo.Filter

      # make a filter that only accepts emails sent from "peter@example.com" or "alex@example.com"
      Filter.all()
      |> Filter.sender_matches(~r/(peter|alex)@example.com/i)
  """
  @spec sender_matches(__MODULE__.t(), Regex.t()) :: __MODULE__.t()
  def sender_matches(%__MODULE__{} = filter, pattern) when is_struct(pattern, Regex) do
    filter.sender_regex == nil ||
      raise "This filter already has a sender match constraint. Filters can only have one of these constraints - to match multiple things, use regex OR patterns."

    %{filter | sender_regex: pattern}
  end
end