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