aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Joe Banks <[email protected]>2025-05-28 22:05:05 +0100
committerGravatar Joe Banks <[email protected]>2025-05-28 22:05:05 +0100
commit843f7c01c9a045c42da7bea75ce0f3365ec4e6a5 (patch)
tree1fa73c17e31b3f54b221e34a8777c2bbf80ba74e
parentAdd leex and yecc files & Elixir interface for DMARC parse (diff)
Add DMARC struct storage and validation
-rw-r--r--lib/dmarc/policy.ex311
1 files changed, 311 insertions, 0 deletions
diff --git a/lib/dmarc/policy.ex b/lib/dmarc/policy.ex
new file mode 100644
index 0000000..2d2f581
--- /dev/null
+++ b/lib/dmarc/policy.ex
@@ -0,0 +1,311 @@
+defmodule Lithium.DMARC.Policy do
+ @moduledoc """
+ Represents a DMARC policy record as per RFC 7489 section 6.
+
+ Default values are filled in the validation methods if not provided.
+
+ Values are translated from raw DMARC options to human-readable atoms at this point.
+
+ Most errors in policy formatting soft-fail to the default values as specified
+ in the RFC document. Processing only halts under certain unrecoverable conditions
+ (e.g. invalid version, invalid policy with no RUA, etc.).
+ """
+ defstruct [
+ :v,
+ :p,
+ :adkim,
+ :aspf,
+ :fo,
+ :pct,
+ :rf,
+ :ri,
+ :rua,
+ :ruf,
+ :sp
+ ]
+
+ @alignment_default :relaxed
+ @fo_default [:all_failure]
+ @pct_default 100
+ @ri_default 86400
+ @rf_default :afrf
+
+ require Logger
+
+ @typedoc """
+ The DMARC version (currently always "DMARC1").
+ """
+ @type v :: :dmarc1
+
+ @typedoc """
+ The DMARC requested mail received policy.
+ """
+ @type p :: :none | :quarantine | :reject
+
+ @typedoc """
+ DKIM identity alignment mode.
+ """
+ @type adkim :: :relaxed | :strict
+
+ @typedoc """
+ SPF identity alignment mode.
+ """
+ @type aspf :: :relaxed | :strict
+
+ @typedoc """
+ All possible DMARC failure reporting options.
+ """
+ @type fo_modes :: :all_failure | :any_failure | :dkim_failure | :spf_failure
+
+ @typedoc """
+ The selected DMARC failure reporting options.
+ """
+ @type fo :: [fo_modes()]
+
+ @typedoc """
+ The percentage of messages subjected to the DMARC policy.
+ """
+ @type pct :: integer()
+
+ @typedoc """
+ The supported reporting formats for aggregate reports.
+ """
+ @type rf_formats :: :afrf
+
+ @typedoc """
+ Selected reporting formats for aggregate reports.
+ """
+ @type rf :: [rf_formats()]
+
+ @typedoc """
+ The interval in seconds between DMARC aggregate reports.
+ """
+ @type ri :: integer()
+
+ @typedoc """
+ The mailto URI for aggregate reports.
+ """
+ @type rua :: String.t()
+
+ @typedoc """
+ The mailto URI for forensic reports.
+ """
+ @type ruf :: String.t()
+
+ @typedoc """
+ The mailto URI for the subdomain policy.
+ """
+ @type sp :: p()
+
+ @typedoc """
+ The DMARC policy record.
+ """
+ @type t :: %__MODULE__{
+ v: v(),
+ p: p(),
+ adkim: adkim(),
+ aspf: aspf(),
+ fo: fo(),
+ pct: pct(),
+ rf: rf(),
+ ri: ri(),
+ rua: [rua()],
+ ruf: [ruf()],
+ sp: sp()
+ }
+
+ @spec parse_version(version :: String.t()) :: {:ok, v()} | {:error, :invalid_version}
+ defp parse_version(version) do
+ case String.downcase(version) do
+ "dmarc1" ->
+ {:ok, :DMARC1}
+
+ _ ->
+ Logger.error("Invalid DMARC version: #{inspect(version)}")
+ {:error, :invalid_version}
+ end
+ end
+
+ # As per RFC 7489, when we parse p= strictness if we find an
+ # invalid value but have a valid RUA, we continue as if we have
+ # found a :none policy.
+ @spec parse_policy(
+ location :: :p | :sp,
+ strictness :: String.t(),
+ has_rua :: boolean()
+ ) :: {:ok, p() | nil} | {:error, :invalid_policy}
+ defp parse_policy(location, policy, has_rua) do
+ case policy do
+ "none" ->
+ {:ok, :none}
+
+ "quarantine" ->
+ {:ok, :quarantine}
+
+ "reject" ->
+ {:ok, :reject}
+
+ _ ->
+ cond do
+ location == :p and has_rua ->
+ {:ok, :none}
+
+ location == :sp ->
+ {:ok, nil}
+
+ true ->
+ Logger.error("Invalid DMARC policy: #{inspect(policy)}")
+ {:error, :invalid_policy}
+ end
+ end
+ end
+
+ @spec parse_alignment_strictness(strictness :: String.t() | nil) ::
+ adkim() | aspf()
+ defp parse_alignment_strictness(strictness) do
+ case strictness do
+ "r" ->
+ :relaxed
+
+ "s" ->
+ :strict
+
+ _ ->
+ # Default to relaxed if not specified
+ @alignment_default
+ end
+ end
+
+ @spec parse_fo(fo :: [String.t()] | nil) :: fo()
+ defp parse_fo(nil), do: @fo_default
+
+ defp parse_fo(fo) do
+ parsed_modes =
+ Enum.map(fo, fn mode ->
+ case mode do
+ "0" -> :all_failure
+ "1" -> :any_failure
+ "d" -> :dkim_failure
+ "s" -> :spf_failure
+ _ -> nil
+ end
+ end)
+
+ parsed_modes = Enum.reject(parsed_modes, &is_nil/1) |> Enum.sort() |> Enum.uniq()
+
+ if parsed_modes == [] do
+ @fo_default
+ else
+ parsed_modes
+ end
+ end
+
+ @spec parse_pct(pct :: String.t() | nil) :: pct()
+ defp parse_pct(nil), do: @pct_default
+
+ defp parse_pct(pct) do
+ case Integer.parse(pct) do
+ {pct, ""} when pct >= 0 and pct <= 100 ->
+ pct
+
+ _ ->
+ @pct_default
+ end
+ end
+
+ @spec parse_rf(rf :: [String.t()]) :: rf()
+ defp parse_rf(nil), do: [@rf_default]
+ defp parse_rf([]), do: [@rf_default]
+
+ defp parse_rf(rf) do
+ parsed_formats =
+ Enum.map(rf, fn format ->
+ case format do
+ "afrf" -> :afrf
+ _ -> nil
+ end
+ end)
+
+ parsed_formats = Enum.reject(parsed_formats, &is_nil/1) |> Enum.sort() |> Enum.uniq()
+
+ if parsed_formats == [] do
+ [@rf_default]
+ else
+ parsed_formats
+ end
+ end
+
+ @spec parse_ri(ri :: String.t() | nil) :: ri()
+ defp parse_ri(nil), do: @ri_default
+
+ defp parse_ri(ri) do
+ case Integer.parse(ri) do
+ {ri, ""} when ri >= 3600 and ri <= 86400 ->
+ # We have to handle daily interval reports, we should handle hourly interval.
+ # We do not *have* to handle anything else.
+ ri
+
+ _ ->
+ @ri_default
+ end
+ end
+
+ @spec parse_addresses(addresses :: String.t() | nil) ::
+ [rua() | ruf()]
+ defp parse_addresses(nil), do: []
+
+ defp parse_addresses(addresses) do
+ addresses
+ |> Enum.map(&parse_address/1)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ @spec parse_address(address :: String.t()) ::
+ rua() | ruf()
+ defp parse_address(address) do
+ uri = URI.parse(address)
+
+ if uri.scheme == "mailto" do
+ uri.path
+ else
+ nil
+ end
+ end
+
+ def from_raw_map(raw_map) do
+ with {:ok, version} <- parse_version(raw_map[:v]),
+ rua <- parse_addresses(raw_map[:rua]),
+ ruf <- parse_addresses(raw_map[:ruf]),
+ {:ok, p} <- parse_policy(:p, raw_map[:p], length(rua) > 0) do
+ adkim = parse_alignment_strictness(raw_map[:adkim])
+ aspf = parse_alignment_strictness(raw_map[:aspf])
+ fo = parse_fo(raw_map[:fo])
+ pct = parse_pct(raw_map[:pct])
+ rf = parse_rf(raw_map[:rf])
+ ri = parse_ri(raw_map[:ri])
+
+ sp =
+ case parse_policy(:sp, raw_map[:sp], length(rua) > 0) |> elem(1) do
+ nil -> p
+ sp -> sp
+ end
+
+ {:ok,
+ %__MODULE__{
+ p: p,
+ v: version,
+ adkim: adkim,
+ aspf: aspf,
+ fo: fo,
+ pct: pct,
+ rf: rf,
+ ri: ri,
+ rua: rua,
+ ruf: ruf,
+ sp: sp
+ }}
+ else
+ error -> error
+ end
+ end
+end