defmodule Integrations.Smtp do @moduledoc """ SMTP server monitor. Opens a TCP connection to an SMTP server, reads the greeting banner, and optionally performs an EHLO handshake to confirm the server is fully operational. Reports the time to first byte and lists advertised capabilities. Uses raw `:gen_tcp` — no external dependencies required. Collection only — see `Integrations.Smtp.Display` (same package) for the dashboard panel. `Display.BundledDefault` auto-hooks it whenever this monitor starts. ## Params * `:host` — SMTP server hostname or IP. Required. * `:port` — Port number. Defaults to `25`. Common alternatives: `465` (SMTPS), `587` (submission), `1025` (Mailpit). * `:helo_domain` — Domain to send in the EHLO command. Defaults to `"raven.local"`. Set to a blank string to skip EHLO and only read the banner. * `:timeout_ms` — TCP connect and receive timeout in milliseconds. Defaults to `5000`. * `:latency_degraded_ms` — Time-to-banner threshold for `:degraded`. Defaults to `1000`. ## Health signal * `:up` — Server responded with a `220` banner; EHLO succeeded (if requested); latency within threshold. * `:degraded` — Banner received but EHLO failed, or latency exceeded `:latency_degraded_ms`. * `:down` — Connection refused, timeout, or non-220 banner. """ use CodeNameRaven.Monitor @default_port 25 @default_helo_domain "raven.local" @default_timeout_ms 5_000 @default_lat_degraded_ms 1_000 @impl true def params_template do %{host: "", port: "25", helo_domain: "raven.local", timeout_ms: "5000"} end @impl true def params_schema do [ host: [type: :string, required: true, doc: "SMTP server hostname or IP"], port: [type: :non_neg_integer, default: 25, doc: "Port number (25, 465, 587, 1025)"], helo_domain: [type: :string, default: "raven.local", doc: "Domain for EHLO command (blank to skip)"], timeout_ms: [type: :non_neg_integer, default: 5_000, doc: "TCP connect and receive timeout in milliseconds"], latency_degraded_ms: [type: :non_neg_integer, default: 1_000, doc: "Time-to-banner threshold for degraded"] ] end @impl true def target_uri(params) do host = get_param(params, :host) port = parse_int(get_param(params, :port), @default_port) if host, do: {:ok, "smtp://#{host}:#{port}"}, else: :none end @impl true def identity_params(params) do %{ host: get_param(params, :host), port: parse_int(get_param(params, :port), @default_port) } end # --------------------------------------------------------------------------- # Collect # --------------------------------------------------------------------------- @impl true def collect(params, state) do host = get_param(params, :host) if is_nil(host) do {:error, "missing required param :host", state} else port = parse_int(get_param(params, :port), @default_port) timeout_ms = parse_int(get_param(params, :timeout_ms), @default_timeout_ms) helo_domain = case get_param(params, :helo_domain) do nil -> @default_helo_domain "" -> nil d -> d end started_at = System.monotonic_time(:millisecond) tcp_opts = [:binary, packet: :line, active: false, send_timeout: timeout_ms] case :gen_tcp.connect(to_charlist(host), port, tcp_opts, timeout_ms) do {:ok, socket} -> result = run_smtp_check(socket, helo_domain, timeout_ms, started_at) :gen_tcp.close(socket) case result do {:ok, data} -> {:ok, data, state} {:error, reason} -> {:error, reason, state} end {:error, :econnrefused} -> {:error, "connection refused on port #{port}", state} {:error, :timeout} -> {:error, "connection timed out", state} {:error, :nxdomain} -> {:error, "hostname not found: #{host}", state} {:error, reason} -> {:error, inspect(reason), state} end end end # --------------------------------------------------------------------------- # Healthy? # --------------------------------------------------------------------------- @impl true def healthy?(result) do lat_deg = result[:latency_degraded_ms] || @default_lat_degraded_ms cond do result.banner_code != 220 -> :down result.ehlo_attempted and not result.ehlo_ok -> :degraded result.banner_ms >= lat_deg -> :degraded true -> :up end end # --------------------------------------------------------------------------- # Metrics # --------------------------------------------------------------------------- @impl true def metrics(result) do base = %{ banner_ms: result.banner_ms, banner_code: result.banner_code } if result.ehlo_attempted do Map.put(base, :capabilities_count, length(result.capabilities)) else base end end # --------------------------------------------------------------------------- # SMTP protocol # --------------------------------------------------------------------------- defp run_smtp_check(socket, helo_domain, timeout_ms, started_at) do # Read greeting — may be multi-line (220-hostname ... \r\n 220 hostname Ready) case recv_smtp_response(socket, timeout_ms) do {:ok, {code, lines}} -> banner_ms = System.monotonic_time(:millisecond) - started_at banner_text = List.first(lines) || "" if helo_domain do :gen_tcp.send(socket, "EHLO #{helo_domain}\r\n") {ehlo_ok, capabilities} = case recv_smtp_response(socket, timeout_ms) do {:ok, {250, caps}} -> {true, caps} _ -> {false, []} end # Polite quit :gen_tcp.send(socket, "QUIT\r\n") {:ok, %{ banner_code: code, banner: banner_text, banner_ms: banner_ms, ehlo_attempted: true, ehlo_ok: ehlo_ok, capabilities: capabilities }} else {:ok, %{ banner_code: code, banner: banner_text, banner_ms: banner_ms, ehlo_attempted: false, ehlo_ok: false, capabilities: [] }} end {:error, reason} -> {:error, reason} end end # Read a complete SMTP response (handles multi-line 250-...\r\n250 ...\r\n) defp recv_smtp_response(socket, timeout_ms, acc \\ []) do case :gen_tcp.recv(socket, 0, timeout_ms) do {:ok, line} -> trimmed = String.trim_trailing(line) case Regex.run(~r/^(\d{3})([ \-])(.*)$/, trimmed) do [_, code_str, " ", text] -> code = String.to_integer(code_str) {:ok, {code, Enum.reverse([text | acc])}} [_, _code_str, "-", text] -> recv_smtp_response(socket, timeout_ms, [text | acc]) _ -> if acc == [] do {:error, "unexpected response: #{trimmed}"} else {:ok, {0, Enum.reverse(acc)}} end end {:error, :timeout} -> {:error, "timed out waiting for server response"} {:error, reason} -> {:error, inspect(reason)} end end # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp get_param(params, key) when is_atom(key) do v = params[key] || params[to_string(key)] if is_binary(v) and String.trim(v) == "", do: nil, else: v end defp parse_int(nil, default), do: default defp parse_int(v, _) when is_integer(v), do: v defp parse_int(v, default) when is_binary(v) do case Integer.parse(v) do {n, _} -> n :error -> default end end defp parse_int(_, default), do: default end