lib/spfcheck.ex

defmodule Spfcheck do
  @external_resource "README.md"
  @moduledoc File.read!("README.md")
             |> String.split("<!-- @MODULEDOC -->")
             |> Enum.fetch!(1)

  alias IO.ANSI

  @options [
    author: :string,
    batch: :integer,
    color: :boolean,
    dns: :string,
    helo: :string,
    help: :boolean,
    ip: :string,
    markdown: :boolean,
    nameserver: :keep,
    report: :string,
    timeout: :integer,
    title: :string,
    verbosity: :integer,
    width: :integer
  ]

  @aliases [
    H: :help,
    a: :author,
    b: :batch,
    c: :color,
    d: :dns,
    h: :helo,
    i: :ip,
    m: :markdown,
    n: :nameserver,
    r: :report,
    t: :title,
    T: :timeout,
    v: :verbosity,
    w: :width
  ]

  @verbosity %{
    :quiet => 0,
    :error => 1,
    :warn => 2,
    :note => 3,
    :info => 4,
    :debug => 5
  }

  @csv_fields [
    :domain,
    :ip,
    :sender,
    :verdict,
    :reason,
    :owner,
    :contact,
    :num_spf,
    :num_dnsm,
    :num_dnsq,
    :num_dnsv,
    :num_checks,
    :num_warn,
    :num_error,
    :duration,
    :explanation
  ]

  # MAIN

  @doc """
  Main entry point for `spfcheck` cli command.

  """
  def main(argv) do
    {opts, senders, _invalid} = OptionParser.parse(argv, aliases: @aliases, strict: @options)

    if Keyword.get(opts, :help, false), do: usage()

    if Keyword.get(opts, :color, true),
      do: Application.put_env(:elixir, :ansi_enabled, true),
      else: Application.put_env(:elixir, :ansi_enabled, false)

    opts = Keyword.put(opts, :log, &log/4)

    if senders == [] do
      do_stdin(opts)
    else
      # used by report to print meta information only once.
      opts = Keyword.put(opts, :first, List.first(senders))

      for sender <- senders do
        Spf.check(sender, opts)
        |> report(opts)
      end
    end
  end

  # Helpers

  defp color(msg, type) do
    iodata =
      case type do
        :error -> ANSI.format([:red_background, :white, msg])
        :warn -> ANSI.format([:light_yellow, msg])
        :note -> ANSI.format([:green, msg])
        :debug -> ANSI.format([:light_blue, msg])
        _ -> msg
      end

    IO.iodata_to_binary(iodata)
  end

  defp log(ctx, facility, severity, msg) do
    # log callback
    if @verbosity[severity] <= ctx.verbosity do
      domain = "#{ctx.map[0]}"
      nth = "#{ctx.nth}"
      fac = "#{facility}"
      sev = "#{severity}"
      depth = String.duplicate("| ", ctx.depth)

      lead = String.pad_trailing("%spf[#{nth}]-#{fac}-#{sev}:", 20, " ") |> color(severity)

      IO.puts(:stderr, "#{domain} #{lead}#{depth}> #{msg}")
    end
  end

  defp text_wrap(text, max, joiner) do
    # simple text wrapper to keep lengthy spf records readable
    if String.length(text) > max do
      String.split(text, ~r/\s+/, trim: true)
      |> assemble("", [], max)
      |> Enum.join(joiner)
    else
      text
    end
  end

  defp assemble([], line, lines, _max),
    do: lines ++ [line]

  defp assemble([word | rest], line, lines, max) do
    if String.length(word) + String.length(line) + 1 > max do
      assemble(rest, "#{word}", lines ++ [line], max)
    else
      prev = if line == "", do: "", else: "#{line} "
      assemble(rest, "#{prev}#{word}", lines, max)
    end
  end

  defp do_stdin(opts) do
    IO.puts(Enum.join(@csv_fields, ","))
    batch = Keyword.get(opts, :batch, 0)

    if batch > 0 do
      Task.Supervisor.start_link(name: Spf.TaskSupervisor)
      do_batch(batch, opts)
    else
      IO.stream()
      |> Enum.each(&do_line(opts, String.trim(&1)))
    end
  end

  defp do_batch(0, _opts),
    do: :ok

  defp do_batch(max, opts) do
    batch =
      IO.stream(:stdio, :line)
      |> Enum.take(max)
      |> Enum.map(&String.trim/1)
      |> Enum.map(
        &Task.Supervisor.async_nolink(Spf.TaskSupervisor, fn -> do_line(opts, &1) end,
          shutdown: 20_000
        )
      )
      |> Enum.map(fn t -> Task.await(t, :infinity) end)

    max =
      case batch do
        [] -> 0
        _ -> max
      end

    do_batch(max, opts)
  end

  # skip comments and empty lines
  defp do_line(_opts, "#" <> _comment), do: nil
  defp do_line(_opts, ""), do: nil

  defp do_line(opts, line) do
    argv = String.split(line, ~r/\s+/, trim: true)
    {parsed, senders, _invalid} = OptionParser.parse(argv, aliases: @aliases, strict: @options)
    opts = Keyword.merge(opts, parsed)

    for sender <- senders do
      Spf.check(sender, opts)
      |> csv_result()
    end
  end

  @spec csv_result(Spf.Context.t()) :: :ok
  defp csv_result(ctx) do
    @csv_fields
    |> Enum.map(fn field -> escape_quotes(ctx[field]) end)
    |> Enum.join(",")
    |> IO.puts()
  end

  defp escape_quotes(""),
    do: ""

  defp escape_quotes(str) when is_binary(str),
    do: "\"#{String.replace(str, ~s("), ~s(""))}\""

  defp escape_quotes(atom) when is_atom(atom),
    do: ":#{atom}"

  defp escape_quotes(arg),
    do: arg

  defp dot_domain(nth, ctx) do
    domain = ctx.map[nth]
    spf = ctx.map[domain]

    # note: copy over dns + macro related fields of the original
    # maybe keep a map of nth -> ast instead of reparsing, but then again
    # how often will a graph be reported?
    Spf.Context.new(domain)
    |> Map.put(:spf, spf)
    |> Map.put(:dns, ctx.dns)
    |> Map.put(:ip, ctx.ip)
    |> Map.put(:sender, ctx.sender)
    |> Map.put(:helo, ctx.helo)
    |> Map.put(:local, ctx.local)
    |> Spf.Parser.parse()
    |> dot_domain_defs(ctx)
  end

  defp dot_domain_defs(%{:spf => ""} = new, ctx) do
    # an include/redirect to a non-existing spf record
    color = "red"

    nths =
      Map.keys(ctx.map)
      |> Enum.filter(fn n -> ctx.map[n] == new.domain end)
      |> Enum.join("][")

    {_, contact} =
      case Spf.DNS.authority(ctx, new.domain) do
        {:ok, _, owner, email} -> {owner, email}
        {:error, reason} -> {"DNS error", "#{reason}"}
      end

    """
    "#{new.domain}" [label=<
      <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0">
      <TR><TD PORT="TOP" BGCOLOR="#{color}">[#{nths}] #{new.domain}</TD></TR>
      <TR><TD BGCOLOR="lightgray">#{contact}</TD></TR>
      <TR><TD>NO SPF</TD></TR>
      </TABLE>
      >, shape="plaintext"];
    """
    |> String.trim()
  end

  defp dot_domain_defs(new, ctx) do
    nths = Map.keys(ctx.map) |> Enum.filter(fn n -> ctx.map[n] == new.domain end)
    errs = Enum.filter(ctx.msg, fn {n, _, s, _} -> n in nths and s == :error end) |> length()
    warn = Enum.filter(ctx.msg, fn {n, _, s, _} -> n in nths and s == :warn end) |> length()
    nths = Enum.join(nths, "][")

    color =
      cond do
        errs > 0 -> "red"
        warn > 0 -> "yellow"
        true -> "green"
      end

    errs = if errs > 0, do: "<TR><TD>#{errs} errors</TD></TR>", else: ""
    warn = if warn > 0, do: "<TR><TD>#{warn} warnings</TD></TR>", else: ""

    entries =
      new.ast
      |> Enum.map(fn {type, args, range} ->
        {new.domain, type, args, String.slice(new.spf, range)}
      end)
      |> Enum.with_index(&dot_node_entry/2)

    rows = Enum.map(entries, fn {row, _vtx} -> row end)
    vert = Enum.map(entries, fn {_row, vtx} -> vtx end) |> Enum.filter(fn v -> v != "" end)

    {_, contact} =
      case Spf.DNS.authority(ctx, new.domain) do
        {:ok, _, owner, email} -> {owner, email}
        {:error, reason} -> {"DNS error", "#{reason}"}
      end

    """
    "#{new.domain}" [label=<
      <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0">
      <TR><TD PORT="TOP" BGCOLOR="#{color}">[#{nths}] #{new.domain}</TD></TR>
      <TR><TD BGCOLOR="lightgray">#{contact}</TD></TR>
      #{Enum.join(rows, "\n  ")}
      #{warn}
      #{errs}
      </TABLE>
      >, shape="plaintext"];

      #{Enum.join(vert, "\n  ")}
    """
    |> String.trim()
  end

  # return a table row definition + vertice if applicable
  # - only include/redirect will point to another SPF record
  # - row uses spf term as found in input string
  # - vertex uses expanded term to point to another record
  defp dot_node_entry({domain, :include, args, term}, idx) do
    name = List.last(args)

    row = "<TR><TD PORT=\"#{idx}\">#{term}</TD></TR>"
    vtx = "\"#{domain}\":\"#{idx}\" -> \"#{name}\":\"TOP\";"
    {row, vtx}
  end

  defp dot_node_entry({domain, :redirect, args, term}, idx) do
    name = List.last(args)

    row = "<TR><TD PORT=\"#{idx}\">#{term}</TD></TR>"
    vtx = "\"#{domain}\":\"#{idx}\" -> \"#{name}\":\"TOP\";"
    {row, vtx}
  end

  defp dot_node_entry({_domain, _type, _args, term}, _idx) do
    row = "<TR><TD>#{term}</TD></TR>"
    {row, ""}
  end

  # Report topics

  defp report(ctx, opts) do
    report = Keyword.get(opts, :report, "") |> String.downcase() |> String.split("", trim: true)
    width = Keyword.get(opts, :width, 60)

    topics =
      case report do
        [] -> ["v"]
        ["a", "l", "l"] -> ["v", "g", "s", "e", "w", "p", "d", "a", "t"]
        topics -> topics
      end

    markdown =
      (length(topics) > 1 and Keyword.get(opts, :markdown, true)) or
        (length(topics) == 1 and Keyword.get(opts, :markdown, false))

    if Keyword.get(opts, :first, nil) == ctx.domain,
      do: meta_data(ctx, markdown, opts)

    if markdown,
      do: IO.puts("\n# #{ctx.domain}"),
      else: IO.puts("")

    ctx = Map.put(ctx, :log, nil)

    for item <- topics, do: topic(ctx, item, markdown, width)
  end

  # Header (meta)
  defp meta_data(_ctx, markdown, opts) do
    if markdown do
      meta =
        """
        ---
        title: #{Keyword.get(opts, :title, "SPF report")}
        author: #{Keyword.get(opts, :author, "spfcheck")}
        date: #{DateTime.utc_now() |> Calendar.strftime("%c")}
        ...
        """
        |> String.trim()

      IO.puts(meta)
    end
  end

  # Verdict
  defp topic(ctx, "v", markdown, width) do
    # wrap verdict in markdown
    if markdown, do: IO.puts("\n## Verdict\n\n```")

    Enum.map(@csv_fields, fn field -> {"#{field}", "#{ctx[field]}"} end)
    |> Enum.map(fn {k, v} -> {String.pad_trailing(k, 11, " "), v} end)
    |> Enum.map(fn {k, v} -> "#{k}: #{v}" end)
    |> Enum.map(&text_wrap(&1, width, "\n             "))
    |> Enum.join("\n")
    |> IO.puts()

    if markdown, do: IO.puts("```"), else: IO.puts("")
  end

  # Spf's
  defp topic(ctx, "s", markdown, width) do
    if markdown, do: IO.puts("\n## SPF\n\n```")
    # donot log DNS stuff to console
    ctx = Map.put(ctx, :verbosity, 0)

    for nth <- 0..(ctx.num_spf - 1) do
      domain = ctx.map[nth]

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

      spf = ctx.map[domain] |> text_wrap(width, "\n    ")
      len = String.length(spf)
      spf = if len < 1, do: "No SPF found", else: spf
      IO.puts("[#{nth}] #{domain} -- #{len} bytes, (#{owner}, #{email})")
      IO.puts("    #{spf}\n")
    end

    if markdown, do: IO.puts("```"), else: IO.puts("")
  end

  # Warnings
  defp topic(ctx, "w", markdown, _width) do
    warnings =
      ctx.msg
      |> Enum.filter(fn t -> elem(t, 2) == :warn end)
      |> Enum.reverse()

    if markdown, do: IO.puts("\n## Warnings\n\n```")

    case warnings do
      [] ->
        IO.puts("No warnings.")

      msgs ->
        for {nth, facility, severity, msg} <- msgs,
            do: IO.puts("%spf[#{nth}]-#{facility}-#{severity}: #{msg}")
    end

    if markdown, do: IO.puts("```"), else: IO.puts("")
  end

  # Errors
  defp topic(ctx, "e", markdown, _width) do
    errors =
      ctx.msg
      |> Enum.filter(fn t -> elem(t, 2) == :error end)
      |> Enum.reverse()

    if markdown, do: IO.puts("\n## Errors\n\n```")

    case errors do
      [] ->
        IO.puts("No errors.")

      msgs ->
        for {nth, facility, severity, msg} <- msgs,
            do: IO.puts("%spf[#{nth}]-#{facility}-#{severity}: #{msg}")
    end

    if markdown, do: IO.puts("```"), else: IO.puts("")
  end

  # Prefixes
  defp topic(ctx, "p", markdown, _width) do
    if markdown, do: IO.puts("\n## Prefixes\n\n```")
    wseen = 2
    width = 39

    IO.puts("#  #{String.pad_trailing("Prefixes", width)} Source(s)")

    for {ip, v} <- Iptrie.to_list(ctx.ipt) do
      seen = String.pad_trailing("#{length(v)}", wseen)
      pfx = "#{ip}" |> String.pad_trailing(width)

      terms =
        for {_q, _nth, donor} <- v do
          donor
        end
        |> Enum.sort()
        |> Enum.join(", ")

      IO.puts("#{seen} #{pfx} #{terms}")
    end

    if markdown, do: IO.puts("```"), else: IO.puts("")
  end

  # DNS
  defp topic(ctx, "d", markdown, width) do
    if markdown, do: IO.puts("\n## DNS\n\n```")

    Spf.DNS.to_list(ctx, valid: true)
    |> Enum.map(fn rr -> text_wrap(rr, width, "\n    ") end)
    |> Enum.join("\n")
    |> IO.puts()

    if markdown, do: IO.puts("```")

    issues = Spf.DNS.to_list(ctx, valid: false)

    if length(issues) > 0 do
      if markdown, do: IO.puts("\n## DNS issues\n\n```")

      issues
      |> Enum.map(fn rr -> text_wrap(rr, width, "\n   ") end)
      |> Enum.join("\n")
      |> IO.puts()

      if markdown, do: IO.puts("```"), else: IO.puts("")
    end
  end

  # Graphviz
  defp topic(ctx, "g", markdown, _width) do
    if markdown, do: IO.puts("\n## Graphviz\n\n```graphviz")

    gdefs = for nth <- 0..(ctx.num_spf - 1), do: dot_domain(nth, ctx)

    # use 0-th domain, not ctx.domain (which might be a redirected domain)
    label =
      "spfcheck(#{ctx.local}@#{ctx.map[0]}, #{ctx.ip})" <>
        " -> #{ctx.verdict},  reason #{ctx.reason}" <>
        "\n#{ctx.explanation}"

    digraph =
      """
      digraph SPF {
        label="#{label}";
        labelloc="t";
        rankdir="LR";
        ranksep="1.0 equally";

        #{Enum.join(gdefs, "\n\n")}
        }
      """
      |> String.trim()

    IO.puts(digraph)

    if markdown, do: IO.puts("```")
  end

  # AST
  defp topic(ctx, "a", markdown, _width) do
    if markdown, do: IO.puts("\n## AST\n\n```")

    ctx.ast
    |> Enum.map(fn x -> inspect(x) end)
    |> Enum.join("\n")
    |> IO.puts()

    if markdown, do: IO.puts("```")
    IO.puts("\nexplain: #{inspect(ctx.explain)}")
  end

  # Tokens
  defp topic(ctx, "t", markdown, _width) do
    if markdown, do: IO.puts("\n## Tokens\n\n```")

    ctx.spf_tokens
    |> Enum.map(fn x -> inspect(x) end)
    |> Enum.join("\n")
    |> IO.puts()

    if markdown, do: IO.puts("```"), else: IO.puts("")
  end

  # Unknown Topic
  defp topic(_ctx, ltr, _markdown, _width),
    do: IO.puts("unknown topic #{ltr} ignored")

  defp usage() do
    IO.puts(@moduledoc)
    exit({:shutdown, 1})
  end
end