DeleteReportedEmail

Use this script to delete a reported phishing email from the mailbox it was reported to.

Type
python
Pack
Phishing

Source

import re
import time
from collections.abc import Callable
from typing import Any
from urllib.parse import quote, unquote

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

DOCS_TROUBLESHOOTING_URL = "https://xsoar.pan.dev/docs/reference/scripts/delete-reported-email#troubleshooting"
EMAIL_INTEGRATIONS = [
    "Gmail",
    "EWSO365",
    "EWS v2",
    "Agari Phishing Defense",
    "MicrosoftGraphMail",
    "Microsoft Graph",
]
# RFC 5322 msg-id is <id-left@id-right> with a constrained charset
MESSAGE_ID_REGEX = re.compile(r"<[^\s<>]+@[^\s<>]+>")
seconds = time.time()

SUCCESS_MESSAGE = "Success - Purge initiated (it may take a few minutes for the email to be removed from the mailbox)"


class MissingEmailException(Exception):
    def __init__(self):
        super().__init__("Email not found in mailbox. It may have been manually deleted.")


class DeletionFailed(Exception):
    pass


class DeletionArgs:
    @staticmethod
    def gmail(search_result: dict, search_args: dict):
        """
        Parse the arguments needed for the delete operation for Gmail integration.
        Args:
            search_result: Results from the previously performed search operation
            search_args: The arguments used for the search operation

        Returns:
            The arguments needed for the deletion operation

        """
        is_permanent = search_args["delete-type"] == "hard"
        gmail_message_id = search_result[0].get("id")
        return {
            "user-id": search_args["user-id"],
            "message-id": gmail_message_id,
            "permanent": is_permanent,
            "using-brand": search_args["using-brand"],
        }

    @staticmethod
    def msgraph(search_result: dict, search_args: dict):
        """
        Parse the arguments needed for the delete operation for O365 - MSGraph integration.
        Args:
            search_result: Results from the previously performed search operation
            search_args: The arguments used for the search operation

        Returns:
            The arguments needed for the deletion operation

        """
        results = search_result[0].get("value", [])
        results = [res for res in results if res.get("internetMessageId") == search_args["message-id"]]
        if not results:
            raise MissingEmailException
        internal_id = results[0].get("id")
        return {
            "user_id": search_args["user_id"],
            "message_id": internal_id,
            "using-brand": search_args["using-brand"],
        }

    @staticmethod
    def agari(search_args: dict):
        """
        Parse the arguments needed for the delete operation for the Agari Phishing Defense integration.
        Args:
            search_args: The arguments used for the search operation

        Returns:
            The arguments needed for the deletion operation

        """
        incident_info = demisto.incident()
        agari_message_id = incident_info.get("CustomFields", {}).get("apdglobalmessageid")
        return {
            "operation": "delete",
            "id": agari_message_id,
            "using-brand": search_args["using-brand"],
        }

    @staticmethod
    def ews(search_result: dict, search_args: dict):
        """
        Parse the arguments needed for the delete operation for EWS integrations (EWS365, EWSv2).
        Args:
            search_result: Results from the previously performed search operation
            search_args: The arguments used for the search operation

        Returns:
            The arguments needed for the deletion operation

        """
        item_id = search_result[0].get("itemId")
        return {
            "item-ids": item_id,
            "delete-type": search_args["delete-type"],
            "target-mailbox": search_args["target-mailbox"],
            "using-brand": search_args["using-brand"],
        }


def check_demisto_version():
    """
    Check if the Cortex XSOAR version is suitable for performing the polling flow (6.2 and above)
    """
    if not is_demisto_version_ge("6.2.0"):
        raise DemistoException(
            "Deleting an email using this script with the Microsoft Graph eDiscovery flow is not "
            "supported by this Cortex XSOAR server version. Please update your server version to 6.2.0 "
            "or later."
        )


def schedule_next_command(args: dict):
    """
    Handle the creation of the ScheduleCommand object
    Returns:
        ScheduleCommand object that will call this script again.
    """
    demisto.debug(f"Scheduling next command for Polling. Current args: {args}")
    polling_args = {
        "interval_in_seconds": 60,
        "polling": True,
        **args,
    }
    # The timeout was increased to 300 sec due to the slowness of the Microsoft eDiscovery process.
    return ScheduledCommand(
        command="DeleteReportedEmail",
        next_run_in_seconds=60,
        args=polling_args,
        timeout_in_seconds=300,
    )


def was_email_already_deleted(search_args: dict, e: str):
    """
    Checks if the email was already deleted by this script, using the context data information.
    Args:
        search_args: the command arguments
        e: error message indicating the email was not found

    Returns:
        'Success', if the email was previously deleted by this script
        'Skipped', if the email was not found in the mailbox and was not previously deleted by this script

    """
    delete_email_from_context = demisto.get(demisto.context(), "DeleteReportedEmail")
    if delete_email_from_context:
        if not isinstance(delete_email_from_context, list):
            delete_email_from_context = [delete_email_from_context]
        for item in delete_email_from_context:
            message_id = item.get("message_id")
            result_str = item.get("result", "")
            if message_id == search_args.get("message-id") and ("Success" in result_str or "Purge" in result_str):
                demisto.debug(f"Email {message_id} was already deleted successfully in a previous run.")
                return result_str, ""
    return "Skipped", e


def _extract_graph_objects(response: Any) -> list:
    """Safely extracts Graph objects from execute_command output whether it's a list, dict, or OData wrapper."""
    demisto.debug(f"_extract_graph_objects called with response type: {type(response)}")
    if isinstance(response, list):
        demisto.debug("Response is a list. Iterating...")
        objects = []
        for item in response:
            objects.extend(_extract_graph_objects(item))
        return objects
    if isinstance(response, dict):
        demisto.debug("Response is a dict.")
        if "value" in response and isinstance(response.get("value"), list):
            demisto.debug(f"Found 'value' array with {len(response.get('value', []))} items.")
            return _extract_graph_objects(response.get("value", []))
        return [response]
    demisto.debug("Response is neither list nor dict. Returning empty list.")
    return []


def msg_resolve_case(using_brand: str, case_name: str) -> str | None:
    """
    Get the ID of an existing eDiscovery case by its display name, creating the case if it does not exist.

    Args:
        using_brand: The brand (integration instance) used to run the eDiscovery commands.
        case_name: The display name of the eDiscovery case to resolve.

    Returns:
        The ID of the existing or newly created case, or None if the case could not be created.
    """
    demisto.debug(f"msg_resolve_case: Attempting to resolve case '{case_name}' using brand '{using_brand}'")
    cases_res = execute_command("msg-list-ediscovery-cases", {"using-brand": using_brand, "all_results": "true"})
    cases = _extract_graph_objects(cases_res)
    demisto.debug(f"msg_resolve_case: Found {len(cases)} existing cases.")

    for case in cases:
        if case.get("displayName") == case_name and case.get("id"):
            demisto.debug(f"msg_resolve_case: Found matching case ID: {case['id']}")
            return case["id"]

    demisto.debug(f"msg_resolve_case: Case '{case_name}' not found. Creating a new one...")
    new_case = execute_command("msg-create-ediscovery-case", {"using-brand": using_brand, "display_name": case_name})
    created_cases = _extract_graph_objects(new_case)
    new_case_id = created_cases[0].get("id") if created_cases else None
    demisto.debug(f"msg_resolve_case: Created new case with ID: {new_case_id}")
    return new_case_id


def microsoft_graph_security_delete_mail(
    args: dict, message_id: str, using_brand: str, delete_type: str, **kwargs
) -> tuple[str, ScheduledCommand | None]:
    """
    Delete an email using the Microsoft Graph eDiscovery flow, with polling between the search and the purge.

    Args:
        args: This script's arguments, also used to persist the case_id and search_id between polling runs.
        message_id: The RFC Message-ID of the email to delete.
        using_brand: The brand (integration instance) used to run the eDiscovery commands.
        delete_type: The deletion type, 'hard' for a permanent delete, otherwise a recoverable delete.
        **kwargs: Additional unused search arguments.

    Returns:
        A tuple of the deletion result ('Success' or 'In Progress') and the ScheduledCommand for the
        next polling run, or None when no further polling is needed.
    """
    demisto.debug(
        f"microsoft_graph_security_delete_mail starting. args: {args}, message_id: {message_id}, delete_type: {delete_type}"
    )
    check_demisto_version()

    already_deleted_res, _ = was_email_already_deleted({"message-id": message_id}, "")
    if already_deleted_res != "Skipped":
        demisto.debug("Email already deleted according to context. Exiting with Success.")
        return already_deleted_res, None

    case_name = "XSOAR Delete Reported Email"
    case_id = args.get("case_id")
    search_id = args.get("search_id")
    demisto.debug(f"Current State -> case_id: {case_id}, search_id: {search_id}")

    # ==========================================
    # STEP 1: First Run - Setup and Trigger Estimate
    # ==========================================
    if not case_id or not search_id:
        demisto.debug("First run detected (missing case_id or search_id). Initializing eDiscovery flow...")
        case_id = msg_resolve_case(using_brand, case_name)
        if not case_id:
            raise DemistoException("Failed to resolve or create an eDiscovery case. case_id is missing.")

        kql_query = f'Identifier:"{message_id}"'
        search_name = f"delete_search_{int(time.time())}"

        demisto.debug(f"Creating search with name '{search_name}' and KQL '{kql_query}' in case '{case_id}'")
        search_res = execute_command(
            "msg-create-ediscovery-search",
            {
                "using-brand": using_brand,
                "case_id": case_id,
                "display_name": search_name,
                "content_query": kql_query,
                "data_source_scopes": "allTenantMailboxes",
            },
        )
        search_objs = _extract_graph_objects(search_res)
        search_id = search_objs[0].get("id") if search_objs else None
        demisto.debug(f"Search created with ID: {search_id}")

        if not search_id:
            raise DemistoException("Failed to create eDiscovery search: No search_id returned.")

        demisto.debug(f"Triggering msg-run-estimate-statistics for case {case_id} and search {search_id}")
        execute_command("msg-run-estimate-statistics", {"using-brand": using_brand, "case_id": case_id, "search_id": search_id})

        args["case_id"] = case_id
        args["search_id"] = search_id

        return "In Progress", schedule_next_command(args)

    # ==========================================
    # STEP 2: Polling Run - Check Estimate Status & Trigger Purge
    # ==========================================
    demisto.debug("Polling run detected. Checking estimate statistics status...")
    status_res = execute_command(
        "msg-get-last-estimate-statistics-operation", {"using-brand": using_brand, "case_id": case_id, "search_id": search_id}
    )

    status_objs = _extract_graph_objects(status_res)
    raw_status = status_objs[0] if status_objs else {}
    status = str(raw_status.get("status", "")).lower()
    demisto.debug(f"Raw status object: {raw_status}")
    demisto.debug(f"Extracted estimate status string: '{status}'")

    if status in ["running", "notstarted", ""]:
        demisto.debug("Estimate operation still running or not started. Returning 'In Progress' to poll again.")
        return "In Progress", schedule_next_command(args)

    if status == "failed":
        demisto.debug(f"Estimate operation failed! Raising DeletionFailed exception for search_id: {search_id}")
        execute_command("msg-delete-ediscovery-search", {"using-brand": using_brand, "case_id": case_id, "search_id": search_id})
        raise DeletionFailed(f"eDiscovery estimate statistics failed for search_id: {search_id}")

    indexed_items = int(raw_status.get("indexedItemCount") or 0)
    total_items = int(raw_status.get("totalItemCount") or 0)
    demisto.debug(f"Estimate operation completed. indexedItemCount: {indexed_items}, totalItemCount: {total_items}")

    if indexed_items == 0 and total_items == 0:
        demisto.debug("No items found. Cleaning up search and raising MissingEmailException.")
        execute_command("msg-delete-ediscovery-search", {"using-brand": using_brand, "case_id": case_id, "search_id": search_id})
        raise MissingEmailException

    purge_type = "permanentlyDelete" if delete_type == "hard" else "recoverable"
    demisto.debug(f"Items found! Triggering msg-purge-ediscovery-data with purge_type '{purge_type}'.")

    try:
        execute_command(
            "msg-purge-ediscovery-data",
            {
                "using-brand": using_brand,
                "case_id": case_id,
                "search_id": search_id,
                "purge_type": purge_type,
                "purge_areas": "mailboxes",
            },
        )
    except Exception as e:
        demisto.debug(f"Purge command failed: {e}. Cleaning up search.")
        execute_command("msg-delete-ediscovery-search", {"using-brand": using_brand, "case_id": case_id, "search_id": search_id})
        raise

    demisto.debug(f"Cleaning up eDiscovery search {search_id} post-purge trigger.")
    execute_command("msg-delete-ediscovery-search", {"using-brand": using_brand, "case_id": case_id, "search_id": search_id})

    demisto.debug("Deletion flow completed successfully via Fire-and-Forget.")
    return SUCCESS_MESSAGE, None


def extract_message_id(search_result: list, search_function: str) -> str | None:
    """Extract the RFC Message-ID from a search result based on the integration's response structure.

    Args:
        search_result: The list returned by ``execute_command`` for the search.
        search_function: The command name used for the search (e.g. ``"gmail-search"``).

    Returns:
        The RFC Message-ID string if available, or ``None`` when the
        result is empty, malformed, or the header is not present.
    """
    demisto.debug(f"extract_message_id: processing search function '{search_function}'")

    if not search_result or not isinstance(search_result, list):
        demisto.debug("extract_message_id: search_result is empty or not a list, returning None")
        return None

    first_result = search_result[0]
    if not isinstance(first_result, dict):
        demisto.debug("extract_message_id: first result is not a dict, returning None")
        return None

    match search_function:
        case "gmail-search":
            # Gmail stores the RFC Message-ID in payload.headers.
            headers = first_result.get("payload", {}).get("headers", [])
            message_id: str | None = None
            for header in headers:
                if header.get("name", "").lower() == "message-id":
                    message_id = header.get("value")
                    break
            demisto.debug(
                f"extract_message_id: Gmail Message-ID header {'found' if message_id else 'not found'} in payload.headers"
            )
            demisto.debug(f"extract_message_id: returning '{message_id}'")
            return message_id

        case "ews-search-mailbox":
            # EWS (O365 / v2) stores the RFC Message-ID under "messageId".
            result = first_result.get("messageId") or None
            demisto.debug(f"extract_message_id: returning '{result}'")
            return result

        case "msgraph-mail-list-emails":
            # MSGraph wraps results in a "value" array; RFC Message-ID is "internetMessageId".
            value_list = first_result.get("value")
            demisto.debug(
                f"extract_message_id: MSGraph value array has {len(value_list) if isinstance(value_list, list) else 'N/A'} items"
            )
            if not isinstance(value_list, list) or not value_list:
                demisto.debug("extract_message_id: returning None (empty or missing value array)")
                return None
            entry = value_list[0]
            if not isinstance(entry, dict):
                demisto.debug("extract_message_id: returning None (first entry is not a dict)")
                return None
            result = entry.get("internetMessageId") or None
            demisto.debug(f"extract_message_id: returning '{result}'")
            return result

        case _:
            demisto.debug(f"extract_message_id: unrecognized search function '{search_function}', returning None")
            return None


def delete_email(
    search_args: dict,
    search_function: str,
    delete_args_function: Callable[[dict, dict], dict] | Callable[[dict], dict],
    delete_function: str,
    deletion_error_condition: Callable[[str], bool] = lambda x: "successfully" not in x,
):
    """
    Generic function to perform the search and delete operations.
    Args:
        search_args: arguments needed to perform the search command.
        search_function: a string representing the search command.
        delete_args_function: a function that parses the arguments needed to perform the search command.
        delete_function: a string representing the delete command.
        deletion_error_condition: a condition to validate if the deletion was successful or not.
    Returns:
        Success if the deletion succeeded, fails otherwise
    """
    demisto.debug(f"Entering standard delete_email flow. search_function: {search_function}, delete_function: {delete_function}")
    if search_function:
        search_result = execute_command(search_function, search_args)
        demisto.debug(
            f"delete_email: search returned {type(search_result).__name__}"
            f" with {len(search_result) if isinstance(search_result, list) else 'N/A'} results"
        )
        if not search_result or isinstance(search_result, str):
            raise MissingEmailException

        will_trigger_guard = isinstance(search_result, list) and len(search_result) > 1
        demisto.debug(f"delete_email: multi-result guard will trigger: {will_trigger_guard}")
        if will_trigger_guard:
            raise DemistoException(
                f"Search returned {len(search_result)} results; expected exactly 1. Refusing delete to avoid ambiguity."
            )

        # verify the returned message matches the expected one
        expected_mid = search_args.get("message-id") or ""
        returned_mid = extract_message_id(search_result, search_function)
        demisto.debug(f"delete_email: returned message-id='{returned_mid}', expected message-id='{expected_mid}'")
        if returned_mid and returned_mid.strip("<>") != expected_mid.strip("<>"):
            raise DemistoException(f"Search returned message {returned_mid} but expected {expected_mid}; refusing delete")
        demisto.debug("delete_email: message-id comparison passed")

        delete_args = delete_args_function(search_result, search_args)  # type: ignore
    else:
        delete_args = delete_args_function(search_args)  # type: ignore

    demisto.debug(f"Executing standard delete command: {delete_function} with args: {delete_args}")
    resp = execute_command(delete_function, delete_args)
    if deletion_error_condition(resp):
        raise DeletionFailed(resp)
    return "Success"


def get_search_args(args: dict):
    """
    Get the parsed arguments needed for the search operation

    Args:
        args: this script's arguments.

    Returns: parsed arguments needed for the search operation
    """
    incident_info = demisto.incident()
    custom_fields = incident_info.get("CustomFields", {})
    message_id = custom_fields.get("reportedemailmessageid") or ""
    if message_id and not MESSAGE_ID_REGEX.fullmatch(message_id):
        raise DemistoException(f"Refusing suspicious Message-ID: {message_id!r}")
    user_id = custom_fields.get("reportedemailto")
    email_subject = custom_fields.get("reportedemailsubject")
    email_origin = custom_fields.get("reportedemailorigin")
    delete_type = args.get("delete_type", custom_fields.get("emaildeletetype", "soft"))
    delete_from_brand = delete_from_brand_handler(incident_info, args)

    missing_field_error_message = (
        f"'{{field_name}}' field could not be found.\nSee {DOCS_TROUBLESHOOTING_URL} for possible solutions."
    )

    if not email_origin or email_origin.lower() == "none":
        raise ValueError(missing_field_error_message.format(field_name="Reported Email Origin"))

    if not message_id:
        raise ValueError(missing_field_error_message.format(field_name="Reported Email Message ID"))

    if not user_id:
        raise ValueError(missing_field_error_message.format(field_name="Reported Email To"))

    if "," in user_id:
        raise ValueError(
            "Script is supporting only deleting mail from one recipient mailbox at a time."
            "Please make sure that there is only one 'Reported Email To' address."
        )

    search_args = {
        "delete-type": delete_type,
        "using-brand": delete_from_brand,
        "email_subject": email_subject,
        "message-id": message_id,
    }
    additional_args = {
        "Gmail": {"query": f'rfc822msgid:"{message_id}"', "user-id": user_id},
        "EWSO365": {"target-mailbox": user_id},
        "EWS v2": {"target-mailbox": user_id},
        "MicrosoftGraphMail": {
            "user_id": user_id,
            "odata": "$filter=internetMessageId eq '{}'".format(quote(unquote(message_id).replace("'", "''"), safe="")),
        },
        "Microsoft Graph": {"to_user_id": user_id},
    }

    search_args.update(additional_args.get(delete_from_brand, {}))
    demisto.debug(f"Generated search args: {search_args}")
    return search_args


def delete_from_brand_handler(incident_info: dict, args: dict):
    """
    Handle the delete_from_brand argument in the following logic:
    1. If the source brand exists in the 'emaildeletefrombrand' field, use it.
    2. If the field is empty, use the script's argument.
    3. If there is no argument given, use the incident's source brand.
    2. If the value is given (in any of the above ways) but it is not of a suitable integration, raise an error.
    Otherwise, use it.

    Args:
        incident_info: Incident info from the context data.
        args: the arguments of this script

    Returns:
        The suitable delete brand

    """
    delete_from_brand = incident_info.get("CustomFields", {}).get("emaildeletefrombrand")
    if not delete_from_brand or delete_from_brand == "Unspecified":
        delete_from_brand = args.get("delete_from_brand", incident_info.get("sourceBrand"))

    elif delete_from_brand not in EMAIL_INTEGRATIONS:
        raise DemistoException(f"Cannot delete the email using the chosen brand. The possible brands are: {EMAIL_INTEGRATIONS}")

    demisto.debug(f"Determined delete_from_brand: {delete_from_brand}")
    return delete_from_brand


def main():
    args = demisto.args()
    search_args = get_search_args(args)
    result, deletion_failure_reason, scheduled_command = "", "", None
    delete_from_brand = search_args["using-brand"]

    try:
        if delete_from_brand == "Microsoft Graph":
            demisto.debug("Routing to Microsoft Graph Security eDiscovery flow.")
            graph_security_args = {k.replace("-", "_"): v for k, v in search_args.items()}
            result, scheduled_command = microsoft_graph_security_delete_mail(args, **graph_security_args)

        else:
            demisto.debug(f"Routing to standard flow for brand: {delete_from_brand}")
            integrations_dict = {
                "Gmail": ("gmail-search", DeletionArgs.gmail, "gmail-delete-mail"),
                "EWSO365": ("ews-search-mailbox", DeletionArgs.ews, "ews-delete-items", lambda x: not isinstance(x, list)),
                "EWS v2": ("ews-search-mailbox", DeletionArgs.ews, "ews-delete-items", lambda x: not isinstance(x, list)),
                "Agari Phishing Defense": (None, DeletionArgs.agari, "apd-remediate-message"),
                "MicrosoftGraphMail": ("msgraph-mail-list-emails", DeletionArgs.msgraph, "msgraph-mail-delete-email"),
            }
            result = delete_email(search_args, *integrations_dict[delete_from_brand])  # type: ignore

    except MissingEmailException as e:
        result, deletion_failure_reason = was_email_already_deleted(search_args, str(e))
    except DeletionFailed as e:
        result, deletion_failure_reason = "Failed", f"Failed deleting email: {e!s}"
    except Exception as e:
        return_error(f"Failed to execute DeleteEmail. Error: {e!s}")

    finally:
        search_args.update({"result": result, "deletion_failure_reason": deletion_failure_reason})
        search_args = remove_empty_elements(replace_in_keys(search_args, "-", "_"))
        demisto.executeCommand("setIncident", {"emaildeleteresult": result, "emaildeletereason": deletion_failure_reason})
        return_results(
            CommandResults(
                readable_output=tableToMarkdown(
                    "Deletion Results",
                    search_args,
                    headerTransform=string_to_table_header,
                ),
                outputs_prefix="DeleteReportedEmail",
                outputs_key_field="message_id",
                raw_response="",
                outputs=search_args,
                scheduled_command=scheduled_command,
            )
        )


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

README

A script for deleting reported phishing emails from the mailbox in which they were reported.

Note

The script was specifically developed for use by the Delete Reported Email layout on the Phishing - Generic v3 playbook, and should not be used elsewhere.

Script Data


Name Description
Script Type python3
Tags basescript
Cortex XSOAR Version 6.1.0

Inputs


Argument Name Description
delete_type The type of deletion - soft allows restoring, hard doesn’t.
delete_from_brand The brand for which to delete this email from. The default value is the incident using the brand.
search_name Argument used for the generic polling flow within the security and compliance search.
polling Use the Cortex XSOAR built-in polling to retrieve the result when it’s ready.
interval_in_seconds Interval in seconds between each poll.

Outputs


Path Description Type
DeleteReportedEmail.result Whether the deletion operation was successful, skipped, or failed String
DeleteReportedEmail.deletion_failure_reason The reason of failure if the deletion operation failed or skipped String
DeleteReportedEmail.delete_type Whether the deletion operation was hard or soft. String
DeleteReportedEmail.using_brand The email service that was used to delete the email. String
DeleteReportedEmail.email_subject The subject of the deleted email. String
DeleteReportedEmail.message_id The message ID of the deleted email. String

Troubleshooting