import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401 import re import ssl import email from datetime import timezone from typing import Any from email.message import Message from dateparser import parse from mailparser import parse_from_bytes, parse_from_string from imap_tools import OR from imapclient import IMAPClient from tempfile import NamedTemporaryFile class Email: def __init__(self, message_bytes: bytes, include_raw_body: bool, save_file: bool, id_: int) -> None: """ Initialize Email class with all relevant data Args: id_: The unique ID with which the email can be fetched from the server specifically message_bytes: The raw email bytes include_raw_body: Whether to include the raw body of the mail in the incident's body save_file: Whether to save the .eml file of the incident's mail """ self.mail_bytes = message_bytes try: email_object = parse_from_bytes(message_bytes) except UnicodeDecodeError as e: demisto.info( f"Failed parsing mail from bytes: [{e}]\n{traceback.format_exc()}." "\nWill replace backslash and try to parse again" ) message_bytes = self.handle_message_slashes(message_bytes) email_object = parse_from_bytes(message_bytes) except TypeError as e: demisto.info(f"Failed parsing mail from bytes: [{e}]\n{traceback.format_exc()}.\nWill try to parse from string") message_string = message_bytes.decode("ISO-8859-1") email_object = parse_from_string(message_string) eml_attachments = self.get_eml_attachments(message_bytes) self.id = id_ self.to = [mail_addresses for _, mail_addresses in email_object.to] self.cc = [mail_addresses for _, mail_addresses in email_object.cc] self.bcc = [mail_addresses for _, mail_addresses in email_object.bcc] self.attachments = email_object.attachments self.attachments.extend(eml_attachments) self.from_ = [mail_addresses for _, mail_addresses in email_object.from_][0] self.format = email_object.message.get_content_type() self.html = email_object.text_html[0] if email_object.text_html else "" self.text = email_object.text_plain[0] if email_object.text_plain else "" self.subject = email_object.subject self.headers = self.parse_headers(email_object.headers) self.raw_body = email_object.body if include_raw_body else None # According to the mailparser documentation the datetime object is in utc self.date = email_object.date.replace(tzinfo=timezone.utc) if email_object.date else None # noqa: UP017 self.raw_json = self.generate_raw_json() self.save_eml_file = save_file self.labels = self._generate_labels() self.message_id = email_object.message_id def parse_email_address_header(self, raw_addresses: list[tuple[str]] | list[list[str]]): """ Parses a list of email address tuples (e.g., [("Name", "email@example.com")]) into a formatted string suitable for email headers. Ensures email addresses are always enclosed in angle brackets. Args: raw_addresses (list): A list of lists or tuples, where each inner list/tuple contains [display_name (str), email_address (str)]. Returns: str: A semicolon-separated string of formatted email addresses. e.g., "Display Name ; another@example.com" """ formatted_addresses = [] for address_data in raw_addresses: if not ( isinstance(address_data, list | tuple) and len(address_data) == 2 and isinstance(address_data[0], str) and isinstance(address_data[1], str) ): demisto.debug(f"Unexpected address data format: {address_data}. Skipping.") continue display_name, email_address = address_data if display_name.strip(): # Check if display_name is not empty or just whitespace formatted_addresses.append(f"{display_name.strip()} <{email_address.strip()}>") else: formatted_addresses.append(f"{email_address.strip()}") return "; ".join(formatted_addresses) def parse_list_header(self, raw_list_values: list[str]): """ Parses a list of string values into a comma-separated string. Args: raw_list_values (list): A list of strings. Returns: str: A comma-separated string of the values. e.g., "value1, value2, value3" """ # Ensure all elements are strings before joining to prevent TypeError string_values = [str(item).strip() for item in raw_list_values] return ", ".join(string_values) def parse_headers(self, raw_headers: dict): """ Parses a dictionary of raw header values into a more standardized format. Args: raw_headers (dict): A dictionary where keys are header names (str) and values can be: - str (for simple headers like Subject) - list of [str, str] tuples/lists (for address headers like From, To) - list of str (for list-based headers like Received) Returns: dict: A new dictionary with parsed header values. """ parsed_headers = {} for header_name, raw_value in raw_headers.items(): if isinstance(raw_value, str): parsed_headers[header_name] = raw_value.strip() elif isinstance(raw_value, list): if header_name in ["From", "To", "Cc", "Bcc", "Delivered-To", "Reply-To"]: # These headers contain email addresses that need special formatting parsed_headers[header_name] = self.parse_email_address_header(raw_value) else: # Other list-based headers (e.g., Received) are joined by commas parsed_headers[header_name] = self.parse_list_header(raw_value) else: demisto.debug(f"Header '{header_name}' has unexpected type {type(raw_value)}. Converting to string.") parsed_headers[header_name] = str(raw_value).strip() return parsed_headers @staticmethod def get_eml_attachments(message_bytes: bytes) -> list: def get_attachment_payload(part: Message) -> bytes: """Returns the payload of the email attachment as bytes object""" payload = part.get_payload(decode=False) if isinstance(payload, list) and isinstance(payload[0], Message): payload = payload[0].as_bytes() elif isinstance(payload, str): payload = payload.encode("utf-8") else: raise DemistoException(f"Could not parse the email attachment: {part.get_filename()}") return payload eml_attachments = [] msg = email.message_from_bytes(message_bytes) if msg: for part in msg.walk(): if part.get_content_maintype() == "multipart" or part.get("Content-Disposition") is None: continue filename = part.get_filename() if filename and filename.endswith(".eml"): eml_attachments.append( { "filename": filename, "payload": get_attachment_payload(part), "binary": False, "mail_content_type": part.get_content_subtype(), "content-id": part.get("content-id"), "content-disposition": part.get("content-disposition"), "charset": part.get_content_charset(), "content_transfer_encoding": part.get_content_charset(), } ) return eml_attachments @staticmethod def handle_message_slashes(message_bytes: bytes) -> bytes: """ Handles the case where message bytes containing backslashes which needs escaping Returns: The message bytes after escaping """ # Input example # 1: # message_bytes = b'\\U' # Output example # 1 (added escaping for the slash): # b'\\\\U' # # Input example # 2: # message_bytes = b'\\\\U' # Output example # 2 (no need to add escaping since the number of slashes is even): # b'\\\\U' regex = re.compile(rb"\\+U", flags=re.IGNORECASE) def escape_message_bytes(m): s = m.group(0) if len(s) % 2 == 0: # The number of slashes prior to 'u' is odd - need to add one backslash s = b"\\" + s return s message_bytes = regex.sub(escape_message_bytes, message_bytes) return message_bytes def _generate_labels(self) -> list[dict[str, str]]: """ Generates the labels needed for the incident Returns: A list of dicts with the form {type: