lib/spf.ex

defmodule Spf do
  @moduledoc """
  Check SPF for a specific `sender` and possible options.

  The `Spf.check/2` function takes a sender and possible options and returns an
  evaluation [`context`](`t:Spf.Context.t/0`) that contains the verdict and
  some statistics of the evaluation.

  ## Example

      iex> unless File.dir?("tmp"), do: File.mkdir("tmp")
      iex> File.write("tmp/zone.txt", \"""
      ...> example.com TXT v=spf1 -all exp=why.%{d}
      ...> why.example.com TXT %{d}: %{i} is not one of our MTA's
      ...> \""")
      :ok
      iex> ctx = Spf.check("example.com", dns: "tmp/zone.txt")
      iex> {ctx.verdict, ctx.reason, ctx.explanation}
      {:fail, "spf[0] -all", "example.com: 127.0.0.1 is not one of our MTA's"}

  """
  @doc """
  Check SPF for given `sender` and possible options.

  Options include:
  - `:dns` filepath or zonedata to pre-populate the context's DNS cache
  - `:helo` the helo presented by sending MTA, defaults to `sender`
  - `:ip` ipv4 or ipv6 address, in binary, of sending MTA, defaults to `127.0.0.1`
  - `:log` a user log/4 function to relay notifications, defaults to `nil`
  - `:verbosity` how verbose the notifications should be (0..5), defaults to `3`
  - `:nameserver` an IPv4 or IPv6 address to use as recursive nameserver

  The keyword list may contain multiple entries of the `:nameserver` option, in
  which case they will be tried in the order listed.

  The user supplied callback `log/4` is called with:
  - `context`, the current evaluation context
  - `facility`, an atom denoting which part of Spf logged the message
  - `severity`, one of: `:error`, `:warn`, `:info`, `:debug`
  - `message`, the log message as a binary

  This is what `Spfcheck` uses to log information to stderr during an SPF
  evaluation.

  This function returns the evaluation context.

  ## Examples

      iex> zone = \"""
      ...> example.com TXT v=spf1 +all
      ...> \"""
      iex> Spf.check("example.com", dns: zone) |> Map.get(:verdict)
      :pass

  """
  @spec check(binary, Keyword.t()) :: Spf.Context.t()
  def check(sender, opts \\ []) do
    Spf.Context.new(sender, opts)
    |> Spf.Eval.evaluate()
    |> add_owner()
  end

  @spec add_owner(Spf.Context.t()) :: Spf.Context.t()
  defp add_owner(ctx) do
    {owner, email} =
      case Spf.DNS.authority(ctx, ctx.domain) do
        {:ok, _, owner, email} -> {owner, email}
        {:error, reason} -> {"DNS error", "#{reason}"}
      end

    Map.put(ctx, :owner, owner)
    |> Map.put(:contact, email)
  end
end