defmodule Integrations.Imap do @moduledoc """ IMAP server monitor. RFC 3501 compliant — works with any IMAP4rev1 server (Dovecot, Cyrus, Courier, etc.), not just Dovecot. Opens a connection to an IMAP server, reads the greeting, fetches capability lists, and optionally authenticates and selects a mailbox to confirm end-to-end mail access. Supports three connection modes: * **IMAPS** (`tls: true`) — implicit TLS from the first byte (port 993). Default. Recommended for all production deployments. * **STARTTLS** (`tls: false, starttls: true`) — plain TCP with inline TLS upgrade before authentication (port 143). * **Plain IMAP** (`tls: false, starttls: false`) — unencrypted, suitable only for in-cluster health checks where the network is trusted. Uses raw `:gen_tcp` / `:ssl` with `packet: :line` — no external dependencies. Collection only — see `Integrations.Imap.Display` (same package) for the dashboard panel. `Display.BundledDefault` auto-hooks it whenever this monitor starts, same as a single-module package would; a release without `raven_web` simply never compiles the display half and runs this monitor headless. ## Params * `:host` — IMAP hostname or IP. Required. * `:port` — Port number. Defaults to `993` (IMAPS). Use `143` for IMAP/STARTTLS. * `:tls` — Use implicit TLS (IMAPS). Defaults to `true`. * `:starttls` — Upgrade plain connection with STARTTLS before auth. Only effective when `tls: false`. Defaults to `false`. * `:verify_tls` — Verify TLS certificate. Defaults to `false` (self-signed certs are common in Kubernetes). * `:user` — Username for `LOGIN` auth check. Optional. * `:password` — Password for `LOGIN` auth check. Optional. * `:mailbox` — Mailbox to `SELECT` after login to verify access. Defaults to `"INBOX"`. Only used when auth credentials are provided. * `:timeout_ms` — TCP connect and receive timeout in milliseconds. Defaults to `5000`. * `:latency_degraded_ms` — Greeting round-trip threshold for `:degraded`. Defaults to `500`. ## Health signal * `:up` — Greeting received; auth succeeded (if checked); mailbox accessible (if checked); latency within threshold. * `:degraded` — Greeting received but latency exceeded threshold. * `:down` — Connection refused, timeout, non-OK greeting, or auth failure. ## Metrics * `latency_ms` — Time from connect to first greeting line * `message_count` — Messages in the selected mailbox (when auth checked) """ use CodeNameRaven.Monitor @default_port 993 @default_timeout_ms 5_000 @default_lat_degraded_ms 500 @impl true def params_template do %{ host: "imap.example.com", port: "993", tls: "true", verify_tls: "false", starttls: "false", user: "", password: "", mailbox: "INBOX" } end @impl true def params_schema do [ host: [type: :string, required: true, doc: "IMAP hostname or IP"], port: [type: :non_neg_integer, default: 993, doc: "Port (993 IMAPS, 143 IMAP/STARTTLS)"], tls: [type: :boolean, default: true, doc: "Use implicit TLS (IMAPS)"], starttls: [type: :boolean, default: false, doc: "Upgrade to TLS via STARTTLS (when tls: false)"], verify_tls: [type: :boolean, default: false, doc: "Verify TLS certificate"], user: [type: :string, required: false, doc: "Username for LOGIN auth check"], password: [type: :string, required: false, doc: "Password for LOGIN auth check"], mailbox: [type: :string, default: "INBOX", doc: "Mailbox to SELECT after login"], 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: 500, doc: "Greeting round-trip 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) tls = truthy?(get_param(params, :tls), default: true) if host, do: {:ok, "#{if tls, do: "imaps", else: "imap"}://#{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) port = parse_int(get_param(params, :port), @default_port) tls = truthy?(get_param(params, :tls), default: true) starttls = truthy?(get_param(params, :starttls), default: false) verify_tls = truthy?(get_param(params, :verify_tls), default: false) user = get_param(params, :user) password = get_param(params, :password) || "" mailbox = get_param(params, :mailbox) || "INBOX" timeout = parse_int(get_param(params, :timeout_ms), @default_timeout_ms) lat_deg = parse_int(get_param(params, :latency_degraded_ms), @default_lat_degraded_ms) if is_nil(host) do {:error, "missing required param :host", state} else ssl_opts = if verify_tls, do: [verify: :verify_peer, cacerts: :public_key.cacerts_get()], else: [verify: :verify_none] case open_conn(host, port, tls, ssl_opts, timeout) do {:ok, conn} -> result = do_imap(conn, ssl_opts, starttls, user, password, mailbox, lat_deg, timeout) close_conn(conn) case result do {:ok, data} -> {:ok, data, state} {:error, reason} -> {:error, reason, state} end {:error, reason} -> {:error, reason, state} end end end # --------------------------------------------------------------------------- # Healthy? # --------------------------------------------------------------------------- @impl true def healthy?(%{auth_ok: false}), do: :down def healthy?(%{latency_ms: lat, latency_degraded_ms: deg}) when is_integer(lat) and is_integer(deg) and lat >= deg, do: :degraded def healthy?(_), do: :up # --------------------------------------------------------------------------- # Metrics # --------------------------------------------------------------------------- @impl true def metrics(result) do %{latency_ms: result.latency_ms} |> maybe_put(:message_count, result[:message_count]) end # --------------------------------------------------------------------------- # IMAP protocol # --------------------------------------------------------------------------- defp do_imap(conn, ssl_opts, starttls, user, password, mailbox, lat_deg, timeout_ms) do t0 = System.monotonic_time(:millisecond) with {:ok, greeting} <- recv_line(conn, timeout_ms) do latency_ms = System.monotonic_time(:millisecond) - t0 if not String.starts_with?(greeting, "* OK") do {:error, "unexpected IMAP greeting: #{String.slice(greeting, 0, 120)}"} else # Capabilities: inline in greeting brackets, or fetch explicitly inline_caps = extract_caps(greeting) {conn, capabilities, next_n} = if Enum.empty?(inline_caps) do case cmd_capability(conn, 1, timeout_ms) do {:ok, caps} -> {conn, caps, 2} _ -> {conn, [], 2} end else {conn, inline_caps, 1} end # STARTTLS upgrade (only when not already TLS) {conn, next_n} = if starttls and match?({:plain, _}, conn) do case cmd_starttls(conn, next_n, ssl_opts, timeout_ms) do {:ok, tls_conn} -> {tls_conn, next_n + 1} _ -> {conn, next_n + 1} end else {conn, next_n} end # LOGIN (optional) {auth_ok, next_n} = if user && user != "" do case cmd_login(conn, next_n, user, password, timeout_ms) do {:ok, :ok} -> {true, next_n + 1} {:ok, :no} -> {false, next_n + 1} _ -> {false, next_n + 1} end else {nil, next_n} end # SELECT mailbox (only when auth succeeded) {message_count, mailbox_ok} = if auth_ok == true do case cmd_select(conn, next_n, mailbox, timeout_ms) do {:ok, count} -> {count, true} _ -> {nil, false} end else {nil, nil} end logout_n = if auth_ok == true, do: next_n + 1, else: next_n cmd_logout(conn, logout_n, timeout_ms) {:ok, %{ latency_ms: latency_ms, latency_degraded_ms: lat_deg, banner: greeting_text(greeting), capabilities: capabilities, auth_ok: auth_ok, mailbox: (if auth_ok == true, do: mailbox), message_count: message_count, mailbox_ok: mailbox_ok }} end end end defp cmd_capability(conn, n, timeout_ms) do tag = imap_tag(n) send_cmd(conn, "#{tag} CAPABILITY") case recv_until_tagged(conn, tag, timeout_ms) do {:ok, :ok, lines} -> caps = Enum.find_value(lines, [], fn line -> case Regex.run(~r/^\* CAPABILITY (.+)/i, String.trim(line)) do [_, cs] -> String.split(cs, ~r/\s+/, trim: true) _ -> nil end end) {:ok, caps} _ -> {:error, "CAPABILITY command failed"} end end defp cmd_starttls(conn, n, ssl_opts, timeout_ms) do tag = imap_tag(n) send_cmd(conn, "#{tag} STARTTLS") case recv_until_tagged(conn, tag, timeout_ms) do {:ok, :ok, _} -> # Switch TCP socket to raw mode before SSL handshake with {:plain, tcp_sock} <- conn do :inet.setopts(tcp_sock, [packet: :raw]) end case upgrade_tls(conn, ssl_opts, timeout_ms) do {:ok, tls_conn} -> {:ok, tls_conn} {:error, r} -> {:error, "TLS upgrade failed: #{inspect(r)}"} end _ -> {:error, "STARTTLS rejected by server"} end end defp cmd_login(conn, n, user, password, timeout_ms) do tag = imap_tag(n) send_cmd(conn, "#{tag} LOGIN #{imap_quote(user)} #{imap_quote(password)}") case recv_until_tagged(conn, tag, timeout_ms) do {:ok, :ok, _} -> {:ok, :ok} {:ok, :no, _} -> {:ok, :no} _ -> {:error, "LOGIN command error"} end end defp cmd_select(conn, n, mailbox, timeout_ms) do tag = imap_tag(n) send_cmd(conn, "#{tag} SELECT #{imap_quote(mailbox)}") case recv_until_tagged(conn, tag, timeout_ms) do {:ok, :ok, lines} -> count = Enum.find_value(lines, fn line -> case Regex.run(~r/^\* (\d+) EXISTS/i, String.trim(line)) do [_, n_str] -> String.to_integer(n_str) _ -> nil end end) {:ok, count} _ -> {:error, "SELECT #{mailbox} failed"} end end defp cmd_logout(conn, n, timeout_ms) do tag = imap_tag(n) send_cmd(conn, "#{tag} LOGOUT") recv_until_tagged(conn, tag, timeout_ms) :ok rescue _ -> :ok catch _, _ -> :ok end # --------------------------------------------------------------------------- # Socket helpers # --------------------------------------------------------------------------- defp open_conn(host, port, true = _tls, ssl_opts, timeout_ms) do opts = [:binary, packet: :line, active: false] ++ ssl_opts case :ssl.connect(to_charlist(host), port, opts, timeout_ms) do {:ok, s} -> {:ok, {:tls, s}} {:error, reason} -> {:error, conn_error(reason, host, port)} end end defp open_conn(host, port, false = _tls, _ssl_opts, timeout_ms) do opts = [:binary, packet: :line, active: false] case :gen_tcp.connect(to_charlist(host), port, opts, timeout_ms) do {:ok, s} -> {:ok, {:plain, s}} {:error, reason} -> {:error, conn_error(reason, host, port)} end end defp upgrade_tls({:plain, tcp_sock}, ssl_opts, timeout_ms) do opts = [:binary, packet: :line, active: false] ++ ssl_opts case :ssl.connect(tcp_sock, opts, timeout_ms) do {:ok, tls_sock} -> {:ok, {:tls, tls_sock}} {:error, r} -> {:error, r} end end defp close_conn({:tls, s}), do: :ssl.close(s) defp close_conn({:plain, s}), do: :gen_tcp.close(s) defp send_cmd({:tls, s}, cmd), do: :ssl.send(s, cmd <> "\r\n") defp send_cmd({:plain, s}, cmd), do: :gen_tcp.send(s, cmd <> "\r\n") defp recv_line({:tls, s}, timeout_ms) do case :ssl.recv(s, 0, timeout_ms) do {:ok, line} -> {:ok, String.trim_trailing(line, "\r\n")} {:error, r} -> {:error, "recv failed: #{inspect(r)}"} end end defp recv_line({:plain, s}, timeout_ms) do case :gen_tcp.recv(s, 0, timeout_ms) do {:ok, line} -> {:ok, String.trim_trailing(line, "\r\n")} {:error, r} -> {:error, "recv failed: #{inspect(r)}"} end end defp recv_until_tagged(conn, tag, timeout_ms, acc \\ []) do case recv_line(conn, timeout_ms) do {:ok, line} -> cond do String.starts_with?(line, tag <> " OK") -> {:ok, :ok, Enum.reverse(acc)} String.starts_with?(line, tag <> " NO") -> {:ok, :no, Enum.reverse(acc)} String.starts_with?(line, tag <> " BAD") -> {:ok, :bad, Enum.reverse(acc)} true -> recv_until_tagged(conn, tag, timeout_ms, [line | acc]) end {:error, reason} -> {:error, reason} end end defp conn_error(:econnrefused, host, port), do: "connection refused at #{host}:#{port}" defp conn_error(:timeout, _host, _port), do: "connection timed out" defp conn_error(:nxdomain, host, _port), do: "hostname not found: #{host}" defp conn_error({:tls_alert, alert}, _, _), do: "TLS alert: #{inspect(alert)}" defp conn_error(reason, _, _), do: inspect(reason) # --------------------------------------------------------------------------- # Parsing helpers # --------------------------------------------------------------------------- # Extracts capabilities from inline greeting bracket: [CAPABILITY IMAP4rev1 ...] defp extract_caps(line) do case Regex.run(~r/\[CAPABILITY ([^\]]+)\]/i, line) do [_, caps] -> String.split(caps, ~r/\s+/, trim: true) _ -> [] end end # Strip "* OK " prefix and bracket section from banner defp greeting_text(line) do line |> String.replace_prefix("* OK ", "") |> String.replace(~r/\[CAPABILITY [^\]]+\]\s*/, "") |> String.trim() end defp imap_tag(n), do: "A#{String.pad_leading(Integer.to_string(n), 3, "0")}" defp imap_quote(s) do escaped = s |> String.replace("\\", "\\\\") |> String.replace("\"", "\\\"") "\"#{escaped}\"" end # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, val), do: Map.put(map, key, val) defp get_param(params, key) when is_atom(key) do v = case Map.fetch(params, key) do {:ok, val} -> val :error -> params[to_string(key)] end 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 defp truthy?(nil, opts), do: Keyword.get(opts, :default, false) defp truthy?("true", _), do: true defp truthy?(true, _), do: true defp truthy?("false", _), do: false defp truthy?(false, _), do: false defp truthy?(_, opts), do: Keyword.get(opts, :default, false) end