Current section
Files
Jump to
Current section
Files
lib/integrations/smtp.ex
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