#!/usr/bin/env python3 # Copyright (C) 2019 Checkmk GmbH - License: GNU General Public License v2 # This file is part of Checkmk (https://checkmk.com). It is subject to the terms and # conditions defined in the file COPYING, which is part of this source code package. # mypy: disable-error-code="possibly-undefined" # Argument 1: Full system path to the pnp4nagios index.php for fetching the graphs. Usually auto configured in OMD. # Argument 2: HTTP-URL-Prefix to open Multisite. When provided, several links are added to the mail. # Example: http://myserv01/prod # # This script creates a nifty HTML email in multipart format with # attached graphs and such neat stuff. Sweet! import socket import sys from collections.abc import Callable, Sequence from datetime import datetime from email.message import Message from typing import NoReturn from jinja2 import Environment, FileSystemLoader from cmk.notification_plugins import utils from cmk.notification_plugins.utils import get_password_from_env_or_context, render_cmk_graphs from cmk.utils.mail import ( Attachment, default_from_address, MailString, multipart_mail, send_mail_sendmail, ) from cmk.utils.paths import omd_root, web_dir # Elements to be put into the mail body. Columns: # 1. Name # 2. "both": always, possible, "host": only for hosts, or "service": only for service notifications # 3. True -> always enabled, not configurable, False: optional # 4. "normal"-> for normal notifications, "alerthandler" -> for alert handler notifications, "all" -> for all types # 5. Title # 6. Text template BODY_ELEMENTS = [ ( "hostname", "both", True, "all", "Host", "$HOSTNAME_AND_ALIAS_TXT$", ), ( "servicedesc", "service", True, "all", "Service", "$SERVICEDESC$", ), ( "event", "both", True, "all", "Event", "$EVENT_TXT$", ), # Elements for both host and service notifications ( "address", "both", False, "all", "Address", "$HOSTADDRESS$", ), ( "abstime", "both", False, "all", "Time", "$LONGDATETIME$", ), ( "omdsite", "both", False, "all", "Site", "$OMD_SITE$", ), ( "hosttags", "both", False, "all", "Host tags", "$HOST_TAGS$", ), ( "notification_author", "both", False, "all", "Notification author", "$NOTIFICATIONAUTHOR$", ), ( "notification_comment", "both", False, "all", "Notification comment", "$NOTIFICATIONCOMMENT$", ), ( "notesurl", "both", False, "all", "Custom host notes URL", "$HOSTNOTESURL$", ), # Elements only for host notifications ( "reltime", "host", False, "all", "Relative time", "$LASTHOSTSTATECHANGE_REL$", ), ( "output", "host", True, "normal", "Summary", "$HOSTOUTPUT$", ), ( "ack_author", "host", False, "normal", "Acknowledge author", "$HOSTACKAUTHOR$", ), ( "ack_comment", "host", False, "normal", "Acknowledge comment", "$HOSTACKCOMMENT$", ), ( "perfdata", "host", False, "normal", "Metrics", "$HOSTPERFDATA$", ), # Elements only for service notifications ( "reltime", "service", False, "all", "Relative time", "$LASTSERVICESTATECHANGE_REL$", ), ( "output", "service", True, "normal", "Summary", "$SERVICEOUTPUT$", ), ( "longoutput", "service", False, "normal", "Details", "$LONGSERVICEOUTPUT$", ), ( "ack_author", "service", False, "normal", "Acknowledge author", "$SERVICEACKAUTHOR$", ), ( "ack_comment", "service", False, "normal", "Acknowledge comment", "$SERVICEACKCOMMENT$", ), ( "perfdata", "service", False, "normal", "Host metrics", "$HOSTPERFDATA$", ), ( "perfdata", "service", False, "normal", "Service metrics", "$SERVICEPERFDATA$", ), ( "notesurl", "service", False, "all", "Custom service notes URL", "$SERVICENOTESURL$", ), # Alert handlers ( "alerthandler_name", "both", True, "alerthandler", "Name of alert handler", "$ALERTHANDLERNAME$", ), ( "alerthandler_output", "both", True, "alerthandler", "Output of alert handler", "$ALERTHANDLEROUTPUT$", ), # Debugging ( "context", "both", False, "all", "Complete variable list", "$CONTEXT_ASCII$", ), ] TMPL_HOST_SUBJECT = "Checkmk: $HOSTNAME$ - $EVENT_TXT$" TMPL_SERVICE_SUBJECT = "Checkmk: $HOSTNAME$/$SERVICEDESC$ $EVENT_TXT$" opt_debug = "-d" in sys.argv bulk_mode = "--bulk" in sys.argv class TemplateRenderer: def __init__(self) -> None: self.env = Environment( loader=FileSystemLoader(omd_root / "share/check_mk/notifications/templates/mail"), autoescape=True, ) self.env.filters["timestamp"] = self.format_timestamp @staticmethod def format_timestamp(value: str) -> str: timestamp = int(value) return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") def render_template(self, template_file: str, data: dict[str, object]) -> str: template = self.env.get_template(template_file) return template.render(data) def send_mail_smtp( message: Message, target: MailString, from_address: MailString, context: dict[str, str] ) -> int: import smtplib host_index = 1 retry_possible = False success = False while not success: host_var = "PARAMETER_SMTP_SMARTHOSTS_%d" % host_index if host_var not in context: break host_index += 1 smarthost = context[host_var] try: send_mail_smtp_impl(message, target, MailString(smarthost), from_address, context) success = True except TimeoutError as e: sys.stderr.write(f'timeout connecting to "{smarthost}": {str(e)}\n') except socket.gaierror as e: sys.stderr.write(f'socket error connecting to "{smarthost}": {str(e)}\n') except smtplib.SMTPRecipientsRefused as e: # the exception contains a dict of failed recipients to the respective error. since we # only have one recipient there has to be exactly one element errorcode, err_message = list(e.recipients.values())[0] # default is to retry, these errorcodes are known to if errorcode not in [ 450, # sender address domain not found 550, # sender address unknown 554, # "Transaction failed" / "Message rejected" ]: retry_possible = True sys.stderr.write( 'mail from %s" to "%s" refused: %d, %r\n' % ( from_address, target, errorcode, err_message, ) ) except smtplib.SMTPHeloError as e: retry_possible = True # server is acting up, this may be fixed quickly sys.stderr.write( f'protocol error from "{smarthost}": {_ensure_str_error_message(e.smtp_error)}\n' ) except smtplib.SMTPSenderRefused as e: sys.stderr.write( f'server didn\'t accept from-address "{from_address}" refused: {_ensure_str_error_message(e.smtp_error)}\n' ) except smtplib.SMTPAuthenticationError as e: sys.stderr.write( f'authentication failed on "{smarthost}": {_ensure_str_error_message(e.smtp_error)}\n' ) except smtplib.SMTPDataError as e: retry_possible = True # unexpected error - give retry a chance sys.stderr.write( f'unexpected error code from "{smarthost}": {_ensure_str_error_message(e.smtp_error)}\n' ) except smtplib.SMTPException as e: retry_possible = True # who knows what went wrong, a retry might just work sys.stderr.write(f'undocumented error code from "{smarthost}": {str(e)}\n') if success: return 0 if retry_possible: return 1 return 2 def _ensure_str_error_message(message: bytes | str) -> str: return message.decode("utf-8") if isinstance(message, bytes) else message def send_mail_smtp_impl( message: Message, target: MailString, smarthost: MailString, from_address: MailString, context: dict[str, str], ) -> None: import smtplib import types def getreply_wrapper(self: smtplib.SMTP) -> tuple[int, bytes]: # We introduce those attributes... self.last_code, self.last_repl = smtplib.SMTP.getreply(self) # type: ignore[attr-defined] return self.last_code, self.last_repl # type: ignore[attr-defined] port = int(context["PARAMETER_SMTP_PORT"]) encryption = context.get("PARAMETER_SMTP_ENCRYPTION", "NONE") conn = ( smtplib.SMTP_SSL(smarthost, port) if encryption == "ssl_tls" else smtplib.SMTP(smarthost, port) ) # TODO: Can we make the hack a bit less evil? # evil hack: the smtplib doesn't allow access to the reply code/message # in case of success. But we want it! conn.last_code = 0 # type: ignore[attr-defined] conn.last_repl = "" # type: ignore[attr-defined] conn.getreply = types.MethodType(getreply_wrapper, conn) # type: ignore[method-assign] if encryption == "starttls": conn.starttls() if context.get("PARAMETER_SMTP_AUTH_USER") is not None: conn.login( context["PARAMETER_SMTP_AUTH_USER"], get_password_from_env_or_context( key="PARAMETER_SMTP_AUTH_PASSWORD", context=context, ), ) # this call returns a dictionary with the recipients that failed + the reason, but only # if at least one succeeded, otherwise it throws an exception. # since we send only one mail per call, we either get an exception or an empty dict. # the first parameter here is actually used in the return_path header try: conn.sendmail(from_address, target.split(","), message.as_string()) sys.stdout.write( "success %d - %s\n" % (conn.last_code, _ensure_str_error_message(conn.last_repl)) # type: ignore[attr-defined] ) finally: conn.quit() # TODO: Use EmailContent parameter. def send_mail(message: Message, target: str, from_address: str, context: dict[str, str]) -> int: if "PARAMETER_SMTP_PORT" in context: return send_mail_smtp(message, MailString(target), MailString(from_address), context) send_mail_sendmail(message, MailString(target), MailString(from_address)) sys.stdout.write("Spooled mail to local mail transmission agent\n") return 0 def render_performance_graphs( context: dict[str, str], ) -> tuple[list[Attachment], list[str]]: attachments: list[Attachment] = [] file_names = [] for graph in render_cmk_graphs(context): attachments.append(Attachment("img", graph.filename, graph.data, "inline")) file_names.append(graph.filename) return attachments, file_names def construct_content( context: dict[str, str], is_bulk: bool = False, bulk_summary: list[dict[str, str]] | None = None, last_bulk_entry: bool = False, notification_number: int = 1, ) -> tuple[str, str, list[Attachment]]: # A list of optional information is configurable via the parameter "elements" # (new configuration style) # Note: The value PARAMETER_ELEMENTSS is NO TYPO. # Have a look at the function events.py:add_to_event_context(..) if "PARAMETER_ELEMENTSS" in context: elements = context["PARAMETER_ELEMENTSS"].split() else: elements = ["graph", "abstime", "address", "longoutput"] if is_bulk and "graph" in elements: # User notification rules using the legacy dict format do not # propagate this parameter, fall back to the same default that # _rbn_finalize_plugin_parameters applies for global rules. notifications_with_graphs = context.get("PARAMETER_NOTIFICATIONS_WITH_GRAPHS", "5") if notification_number > int(notifications_with_graphs): elements.remove("graph") # Prepare the text mail content template_txt = body_templates( context["WHAT"].lower(), "ALERTHANDLEROUTPUT" in context, elements, BODY_ELEMENTS, ) content_txt = utils.substitute_context(template_txt, context) attachments: list[Attachment] = [] file_names: list[str] = [] if "graph" in elements and "ALERTHANDLEROUTPUT" not in context: # Add Checkmk graphs try: attachments, file_names = render_performance_graphs(context) except Exception as e: sys.stderr.write("Failed to add graphs to mail. Continue without them. (%s)\n" % e) content_html = utils.substitute_context( TemplateRenderer().render_template( "base.html.jinja", { "data": context, "graphs": file_names, "insert": context.get("PARAMETER_INSERT_HTML_SECTION", ""), "is_bulk": is_bulk, "bulk_summary": bulk_summary, "last_bulk_entry": last_bulk_entry, }, ), context, ) return ( content_txt, content_html, attachments, ) def extend_context(context: dict[str, str], is_bulk: bool = False) -> None: context["LINKEDHOSTNAME"] = utils.format_link( '%s', utils.host_url_from_context(context), context["HOSTNAME"], ) context["LINKEDSERVICEDESC"] = utils.format_link( '%s', utils.service_url_from_context(context), context.get("SERVICEDESC", ""), ) # For "Additional details", graph is a default if "graph" in context.get("PARAMETER_ELEMENTSS", "graph").split(): context["GRAPH_URL"] = utils.graph_url_from_context(context) if context["HOSTALIAS"] and context["HOSTNAME"] != context["HOSTALIAS"]: context["HOSTNAME_AND_ALIAS_TXT"] = "$HOSTNAME$ ($HOSTALIAS$)" context["HOSTNAME_AND_ALIAS_HTML"] = "$LINKEDHOSTNAME$ ($HOSTALIAS$)" else: context["HOSTNAME_AND_ALIAS_TXT"] = "$HOSTNAME$" context["HOSTNAME_AND_ALIAS_HTML"] = "$LINKEDHOSTNAME$" if "HOSTOUTPUT" in context: context["HOSTOUTPUT_HTML"] = context["HOSTOUTPUT"] if context["WHAT"] == "SERVICE": context["SERVICEOUTPUT_HTML"] = context["SERVICEOUTPUT"] long_serviceoutput = ( context["LONGSERVICEOUTPUT"].replace("\\n", "
").replace("\n", "
") ) context["LONGSERVICEOUTPUT_HTML"] = long_serviceoutput # Compute the subject of the mail if context["WHAT"] == "HOST": tmpl = context.get("PARAMETER_HOST_SUBJECT") or TMPL_HOST_SUBJECT context["SUBJECT"] = utils.substitute_context(tmpl, context) else: tmpl = context.get("PARAMETER_SERVICE_SUBJECT") or TMPL_SERVICE_SUBJECT context["SUBJECT"] = utils.substitute_context(tmpl, context) def body_templates( what: str, is_alert_handler: bool, elements: list[str], body_elements: list[tuple[str, str, bool, str, str, str]], ) -> str: tmpl_txt: list[str] = [] for name, whence, forced, nottype, title, txt in body_elements: if nottype == "alerthandler" and not is_alert_handler: continue if nottype not in ("alerthandler", "all") and is_alert_handler: continue if (whence in ("both", what)) and (forced or (name in elements)): tmpl_txt += "%-20s %s\n" % (title + ":", txt) return "".join(tmpl_txt) # TODO: NamedTuple? class EmailContent: def __init__( self, context: dict[str, str], mailto: str, subject: str, from_address: str, reply_to: str, content_txt: str, content_html: str, attachments: Sequence[Attachment], ) -> None: self.context = context self.mailto = mailto self.subject = subject self.from_address = from_address self.reply_to = reply_to self.content_txt = content_txt self.content_html = content_html self.attachments = attachments class BulkEmailContent(EmailContent): def __init__( self, context_function: Callable[[], tuple[dict[str, str], list[dict[str, str]]]] ) -> None: attachments = [] content_txt = "" content_html = "" parameters, contexts = context_function() hosts = set() all_contexts_updated: list[dict[str, str]] = [] for single_context in contexts: single_context.update(parameters) escaped_context = utils.html_escape_context(single_context) extend_context(escaped_context, is_bulk=True) all_contexts_updated.append(escaped_context) bulk_summary = _get_bulk_summary(all_contexts_updated) content_html += TemplateRenderer().render_template( "base.html.jinja", { "data": { "EVENTHISTORYURL": utils.eventhistory_url_from_context(all_contexts_updated[0]) }, "graphs": [], "insert": "", "is_bulk": True, "bulk_summary": bulk_summary, "last_bulk_entry": False, }, ) for i, c in enumerate(all_contexts_updated, 1): txt, html, att = construct_content( c, is_bulk=True, bulk_summary=None, last_bulk_entry=i == len(all_contexts_updated), notification_number=i, ) content_txt += txt content_html += html attachments += att hosts.add(c["HOSTNAME"]) attachments = _add_template_attachments(escaped_context, attachments) # TODO: cleanup duplicate code with SingleEmailContent # TODO: the context is only needed because of SMPT settings used in send_mail super().__init__( context=escaped_context, # Assume the same in each context mailto=escaped_context["CONTACTEMAIL"], # Use the single context subject in case there is only one context in the bulk subject=( utils.get_bulk_notification_subject(contexts, hosts) if len(contexts) > 1 else escaped_context["SUBJECT"] ), from_address=utils.format_address( escaped_context.get("PARAMETER_FROM_DISPLAY_NAME", ""), # TODO: Correct context parameter??? escaped_context.get("PARAMETER_FROM_ADDRESS", default_from_address()), ), reply_to=utils.format_address( escaped_context.get("PARAMETER_REPLY_TO_DISPLAY_NAME", ""), escaped_context.get("PARAMETER_REPLY_TO", ""), ), content_txt=content_txt, content_html=content_html, attachments=attachments, ) def _get_bulk_summary(all_contexts_updated: list[dict[str, str]]) -> list[dict[str, str]]: keys_to_include = { "WHAT", "PREVIOUSHOSTHARDSTATE", "HOSTSTATE", "HOSTNAME_AND_ALIAS_HTML", "PREVIOUSSERVICEHARDSTATE", "SERVICESTATE", "LINKEDSERVICEDESC", "SHORTDATETIME", "LASTHOSTSTATECHANGE", "LASTSERVICESTATECHANGE", } keys_to_substitute = {"HOSTNAME_AND_ALIAS_HTML", "LINKEDSERVICEDESC"} return [ { key: utils.substitute_context(part[key], part) if key in keys_to_substitute else part[key] for key in keys_to_include if key in part } for part in all_contexts_updated ] class SingleEmailContent(EmailContent): def __init__(self, context_function: Callable[[], dict[str, str]]) -> None: # gather all options from env context = context_function() escaped_context = utils.html_escape_context(context) extend_context(escaped_context) content_txt, content_html, attachments = construct_content(escaped_context) attachments = _add_template_attachments(context, attachments) # TODO: cleanup duplicate code with BulkEmailContent # TODO: the context is only needed because of SMPT settings used in send_mail super().__init__( context=escaped_context, mailto=escaped_context["CONTACTEMAIL"], subject=escaped_context["SUBJECT"], from_address=utils.format_address( escaped_context.get("PARAMETER_FROM_DISPLAY_NAME", ""), escaped_context.get("PARAMETER_FROM_ADDRESS", default_from_address()), ), reply_to=utils.format_address( escaped_context.get("PARAMETER_REPLY_TO_DISPLAY_NAME", ""), escaped_context.get("PARAMETER_REPLY_TO_ADDRESS", ""), ), content_txt=content_txt, content_html=content_html, attachments=attachments, ) def _add_template_attachments( context: dict[str, str], attachments: list[Attachment], ) -> list[Attachment]: # always needed for icon in [ "checkmk_logo.png", "overview.png", ]: attachments.append(attach_file(icon=icon)) if context.get("PARAMETER_CONTACT_GROUPS"): attachments.append(attach_file(icon="contact_groups.png")) # TODO these elements have to be removed from the choices, they are always # shown in other sections of the HTML email if elements := context.get("PARAMETER_ELEMENTSS", "graph abstime longoutput").split(): if "graph" in elements: attachments.append(attach_file(icon="graph.png")) elements.remove("graph") if "longoutput" in elements: elements.remove("longoutput") if "abstime" in elements: elements.remove("abstime") if "omdsite" in elements: elements.remove("omdsite") if "address" in elements: elements.remove("address") if elements: attachments.append(attach_file(icon="additional.png")) if context.get("PARAMETER_SVC_LABELS") or context.get("PARAMETER_HOST_LABELS"): attachments.append(attach_file(icon="label.png")) if context.get("PARAMETER_HOST_TAGS"): attachments.append(attach_file(icon="vector.png")) return attachments def attach_file(icon: str) -> Attachment: with open(web_dir / f"htdocs/images/icons/{icon}", "rb") as file: return Attachment(what="img", name=icon, contents=file.read(), how="inline") def main() -> NoReturn: content = ( BulkEmailContent(utils.read_bulk_contexts) if bulk_mode else SingleEmailContent(utils.collect_context) ) if not content.mailto: # e.g. empty field in user database sys.stderr.write("Cannot send HTML email: empty destination email address\n") sys.exit(2) m = multipart_mail( content.mailto, content.subject, content.from_address, content.reply_to, content.content_txt, content.content_html, content.attachments, ) try: sys.exit( send_mail( m, content.mailto, content.from_address, content.context, ) ) except Exception as e: sys.stderr.write("Unhandled exception: %s\n" % e) # unhandled exception, don't retry this... sys.exit(2)