DisplayEmailHtmlThread

Dynamic-section script for 'Email Threads' layout. This script renders all email messages with the thread number specified in the "Email Selected Thread" field and outputs them as a single HTML output.

python · Email Communication

Source

import html as html_module
import re

import demistomock as demisto  # noqa: F401
from bs4 import BeautifulSoup
from CommonServerPython import *  # noqa: F401

ALLOWED_EMAIL_TAGS = {
    "p",
    "br",
    "div",
    "span",
    "b",
    "i",
    "u",
    "a",
    "img",
    "table",
    "tr",
    "td",
    "th",
    "thead",
    "tbody",
    "ul",
    "ol",
    "li",
    "h1",
    "h2",
    "h3",
    "h4",
    "h5",
    "h6",
    "pre",
    "code",
    "blockquote",
    "strong",
    "em",
    "hr",
    "font",
    "center",
    "small",
    "big",
    "sub",
    "sup",
    "dl",
    "dt",
    "dd",
    "caption",
}

ALLOWED_EMAIL_ATTRIBUTES: dict[str, set[str]] = {
    "*": {"style", "class", "id", "dir", "align", "width", "height", "bgcolor", "valign"},
    "a": {"href", "target"},
    "img": {"src", "alt", "title"},
    "td": {"colspan", "rowspan"},
    "th": {"colspan", "rowspan", "scope"},
    "font": {"color", "face", "size"},
    "table": {"border", "cellpadding", "cellspacing"},
}


def sanitize_html_body(html_body: str) -> str:
    """Sanitize email body HTML using an allowlist of tags and attributes.

    When nh3 is available, strips disallowed tags and attributes while preserving safe ones.
    When nh3 is not available, returns the HTML as-is since full escaping
    would break legitimate formatting in an HTML rendering context.
    """
    try:
        import nh3

        return nh3.clean(html_body, tags=ALLOWED_EMAIL_TAGS, attributes=ALLOWED_EMAIL_ATTRIBUTES)  # pylint: disable=no-member
    except ImportError:
        demisto.debug("nh3 is not available; HTML sanitization skipped")
        return html_body


no_entries_message = """<!DOCTYPE html>
<html>
<body>
<h3>This Incident does not contain any email threads yet.</h3>
</body>
</html>
"""


def set_email_reply(email_from, email_to, email_cc, email_subject, html_body, email_time, attachment_names):
    """Set the email reply from the given details.
    Args:
        email_from: The email author mail.
        email_to: The email recipients.
        email_cc: The email cc.
        email_subject: The email subject.
        html_body: The email HTML body.
        email_time: The time the email was received
        attachment_names: String of attachment names
    Returns:
        str. Email reply.
    """

    safe_from = html_module.escape(email_from or "")
    safe_to = html_module.escape(email_to or "")
    safe_cc = html_module.escape(email_cc or "")
    safe_subject = html_module.escape(email_subject or "")
    safe_time = html_module.escape(email_time or "")
    safe_attachments = html_module.escape(attachment_names or "")

    single_reply = (
        f"<html><body><b>From:</b> {safe_from}<br><b>To:</b> {safe_to}<br><b>CC:</b> {safe_cc}<br>"
        f"<b>Subject:</b> {safe_subject}<br><b>Email Time:</b> {safe_time}<br>"
        f"<b>Attachments:</b> {safe_attachments}</body></html>"
    )

    single_reply += (
        f'\n{sanitize_html_body(html_body or "")}\n'
        f'<hr style="width:98%;text-align:center;height:3px;border-width:0;background-color:#cccccc">\n\n'
    )

    return single_reply


def rewrite_img_src(html: str, account_name: str = None) -> str:
    """
    Replace:
      src="xsoar/entry/download/<id>"
    With:
      src="xsoar/<account_name>/entry/download/<id>"
    """
    if not account_name:
        return html

    pattern = r'src="xsoar/entry/download/([^"]+)"'
    replacement = rf'src="xsoar/{account_name}/entry/download/\1"'

    return re.sub(pattern, replacement, html)


def html_cleanup(full_thread_html, account_name=None):
    """
        Moves various HTML tags so the final output is a single HTML document
    Args:
        full_thread_html (string): The concatenated string of all email HTML
    Returns:
        str. Full email thread HTML
    """
    # Remove HTML tags within the string that should only occur once
    full_thread_html = re.sub("(?i)<!DOCTYPE html>", "", full_thread_html)
    full_thread_html = re.sub("(?i)<.?html>", "", full_thread_html)
    full_thread_html = re.sub("(?i)<.?body>", "", full_thread_html)

    # Place needed HTML tags in their appropriate locations
    final_html_result = f"<!DOCTYPE html>\n<html>\n<body>\n{full_thread_html}\n</body>\n</html>"

    if account_name:
        final_html_result = rewrite_img_src(final_html_result, account_name)

    return final_html_result


def remove_color_from_html_text(html_message):
    """Remove the color from the html text, so the color will be determined by the front-end.
    Args:
        html_message: The content of the HTML that the color attribute should be removed from.
    Returns:
        str. The updated HTML, without the color attribute.
    """
    parsed_html_body = BeautifulSoup(html_message, "html.parser")

    # Remove style attributes of color
    for tag in parsed_html_body.find_all(True):
        if "style" in tag.attrs and tag.attrs["style"] and "color" in tag.attrs["style"]:
            demisto.debug(f"The original style att {tag.attrs['style']=}")
            new_style = ""
            style_attr = str(tag.attrs["style"]).split(";")
            for attr in style_attr:
                if "color" not in attr:
                    new_style += f"{attr};"
                else:  # Can be color, background-color, etc. Remove only the color of the text.
                    color_attr = attr.split(":")
                    if not (color_attr[0] == "color" or color_attr[0] == " color"):
                        new_style += f"{attr};"
            tag.attrs["style"] = new_style
            demisto.debug(f"The new style att {tag.attrs['style']=}")

        if "color" in tag.attrs:
            demisto.debug(f"Removed the color att {tag.attrs['color']} from the tag {tag=}")
            del tag.attrs["color"]
    return str(parsed_html_body)


def main():
    args = demisto.args()
    incident = demisto.incident()
    account_name = args.get("account_name", "")
    custom_fields = incident.get("CustomFields")
    thread_number = custom_fields.get("emailselectedthread", 0)
    incident_context = demisto.context()
    email_threads = incident_context.get("EmailThreads", {})

    if len(email_threads) == 0:
        return_results({"ContentsFormat": EntryFormat.HTML, "Type": EntryType.NOTE, "Contents": no_entries_message})
        return

    if isinstance(email_threads, dict):
        email_threads = [email_threads]

    thread_exists = False
    thread_items = []
    full_thread_html = ""

    for thread in email_threads:
        if str(thread["EmailCommsThreadNumber"]) == str(thread_number):
            thread_exists = True
            thread_dict = {
                "email_from": thread.get("EmailFrom", None),
                "email_cc": thread.get("EmailCC", None),
                "email_to": thread.get("EmailTo", None),
                "email_subject": thread.get("EmailSubject", None),
                "email_html": remove_color_from_html_text(thread.get("EmailHTML")) if thread.get("EmailHTML") else None,
                "email_time": thread.get("MessageTime", None),
                "email_attachments": thread.get("EmailAttachments", None),
            }
            demisto.debug(f'DisplayEmailHtmlThread - {thread_dict.get("email_html")=}')
            thread_items.append(thread_dict)

    if thread_exists:
        # Append all messages together, with the most recent on top
        for thread in reversed(thread_items):
            email_reply = set_email_reply(
                thread.get("email_from", None),
                thread.get("email_to", None),
                thread.get("email_cc", None),
                thread.get("email_subject", None),
                thread.get("email_html", None),
                thread.get("email_time", None),
                thread.get("email_attachments", None),
            )
            full_thread_html += email_reply

        final_html_result = html_cleanup(full_thread_html, account_name)
        return_results({"ContentsFormat": EntryFormat.HTML, "Type": EntryType.NOTE, "Contents": final_html_result})
    else:
        return_error(f"An email thread of {thread_number} was not found. Please make sure this thread number is correct.")


if __name__ in ("__builtin__", "builtins", "__main__"):
    main()

README

Renders all email messages in the selected thread as a single HTML entry, with the oldest message appearing first and all subsequent messages below.

Script Data


Name Description
Script Type python3
Tags dynamic-section

Inputs


There are no inputs for this script.

Outputs


There are no outputs for this script.