core/alert/handler/email.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule AntikytheraCore.Alert.Handler.Email do
  @default_errors_per_body 3

  @moduledoc """
  Alert handler implementation that sends an email.

  This is considered the default alert backend (and also used for testing of `AntikytheraCore.Alert.Manager`).
  Email delivery method is provided by a callback module of `AntikytheraEal.AlertMailer.Behaviour`.

  ## Handler config

  - `to` - Required. List of email addresses to be sent. Must not be an empty list.
  - `errors_per_body` - Optional. Integer number of errors printed in body of an alert mail.
    Details of errors beyond this threshold will be omitted.
    Defaults to #{@default_errors_per_body}.
  """

  alias Antikythera.{Email, Time, Env}
  alias AntikytheraCore.Cluster.NodeId
  alias AntikytheraCore.Alert.HandlerConfig
  alias AntikytheraEal.AlertMailer, as: AM

  @behaviour AntikytheraCore.Alert.Handler

  @from Application.compile_env!(:antikythera, :alert) |> get_in([:email, :from])
  if !Email.valid?(@from) do
    raise "please set a valid email address in application config (as nested keyword list of `[:alert, :email, :from]`)"
  end

  @impl true
  def send_alerts([], _, _) do
    []
  end

  def send_alerts(messages, %{"to" => to} = handler_config, otp_app_name) do
    epb =
      case Map.get(handler_config, "errors_per_body") do
        int when is_integer(int) -> int
        _anything_else -> @default_errors_per_body
      end

    mail = %AM.Mail{
      from: @from,
      to: to,
      subject: subject(messages, otp_app_name),
      body: body(messages, epb)
    }

    # just spawn and forget, since async/await handling in :gen_event handler is cumbersome
    _ = spawn(AM, :deliver, [mail])
    []
  end

  defp subject([{_time, body}], otp_app_name), do: tag(otp_app_name) <> headline(body)

  defp subject(messages, otp_app_name) do
    [{_time, body} | tl] = messages
    "#{tag(otp_app_name)}#{headline(body)} [and other #{length(tl)} error(s)]"
  end

  defp tag(otp_app_name) do
    "<ALERT>[#{otp_app_name}][#{Env.runtime_env()}][#{NodeId.get()}] "
  end

  defp body(messages, errors_per_body) do
    {messages_with_full_body, messages_without_full_body} = Enum.split(messages, errors_per_body)

    Enum.join([
      Enum.map(messages_with_full_body, fn {t, b} -> time_body(t, b) end),
      Enum.map(messages_without_full_body, fn {t, b} -> time_headline(t, b) end)
    ])
  end

  defp time_headline(time, body), do: "[#{Time.to_iso_timestamp(time)}] #{headline(body)}\n"

  defp time_body(time, body), do: "[#{Time.to_iso_timestamp(time)}] #{body}\n\n\n"

  defp headline(body) do
    body
    |> truncate_by_length(50)
    |> String.split("\n", parts: 2)
    |> hd()
  end

  defp truncate_by_length(str, length) do
    case String.split_at(str, length) do
      {head_str, ""} -> head_str
      {head_str, _tail_str} -> head_str <> "..."
    end
  end

  @impl true
  defun validate_config(config :: HandlerConfig.t()) :: boolean do
    %{"to" => [_ | _] = addresses} -> addresses_valid?(addresses)
    _ -> false
  end

  defp addresses_valid?(addresses) do
    Enum.all?(addresses, &Email.valid?/1)
  end
end