From 7ae17956d5a8d1566b5d365bd63775986bfe9021 Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 10:45:55 +0300 Subject: [PATCH 01/12] feat(mailgun): Improve email parsing with enhanced DMARC detection and flexible handling - Add comprehensive DMARC report detection using multiple indicators (sender, subject, content-type) - Add email type classification (DMARC, SPF, bounce, alert) - Add configurable skip options for DMARC/SPF reports via UI - Handle emails without body content gracefully with fallback messages - Improve error handling and logging for better debugging - Add email_type metadata to all alerts for better tracking Fixes parsing errors for DMARC reports that have no body content. --- .../mailgun_provider/mailgun_provider.py | 465 ++++++++++++++---- 1 file changed, 376 insertions(+), 89 deletions(-) diff --git a/keep/providers/mailgun_provider/mailgun_provider.py b/keep/providers/mailgun_provider/mailgun_provider.py index 99f64601ed..3fe39f0137 100644 --- a/keep/providers/mailgun_provider/mailgun_provider.py +++ b/keep/providers/mailgun_provider/mailgun_provider.py @@ -55,6 +55,33 @@ class MailgunProviderAuthConfig: "hint": "Read more about extraction in Keep's Mailgun documentation", }, ) + skip_dmarc_reports: bool = dataclasses.field( + default=True, + metadata={ + "required": False, + "description": "Skip DMARC reports", + "hint": "Automatically skip DMARC aggregate reports", + "type": "switch", + }, + ) + skip_spf_reports: bool = dataclasses.field( + default=True, + metadata={ + "required": False, + "description": "Skip SPF reports", + "hint": "Automatically skip SPF failure reports", + "type": "switch", + }, + ) + handle_emails_without_body: bool = dataclasses.field( + default=True, + metadata={ + "required": False, + "description": "Handle emails without body content", + "hint": "Create alerts for emails that only have subject/attachments", + "type": "switch", + }, + ) class MailgunProvider(BaseProvider): @@ -239,107 +266,367 @@ def setup_webhook( return {"route_id": route_id, "email": email} @staticmethod - def _format_alert( - event: dict, provider_instance: "MailgunProvider" = None - ) -> AlertDto: - # We receive FormData here, convert it to simple dict. - logger.info( - "Received alert from mail", - extra={ - "from": event["from"], - "subject": event.get("subject") - }, - ) - event = dict(event) - - source = event["from"] - name = event.get("subject", source) - body_plain = event.get("Body-plain") - message = event.get("stripped-text", body_plain) + def _is_dmarc_report(event: dict) -> bool: + """ + Detect DMARC reports using multiple indicators. + + Args: + event: Email event data + + Returns: + bool: True if email is a DMARC report + """ + # Check sender patterns + sender = event.get("from", "").lower() + dmarc_senders = [ + "noreply-dmarc-support@google.com", + "dmarc-support@google.com", + "dmarc@", + "postmaster@", + "noreply@google.com" + ] + + if any(dmarc_sender in sender for dmarc_sender in dmarc_senders): + return True + + # Check subject patterns + subject = event.get("subject", "").lower() + if any(pattern in subject for pattern in ["report domain:", "dmarc", "aggregate report"]): + return True + + # Check content type for ZIP attachments (DMARC reports are typically ZIP files) + content_type = event.get("Content-Type", "").lower() + if "application/zip" in content_type: + return True + + # Check raw content if available raw_content = event.get("raw_content") + if raw_content: + if isinstance(raw_content, bytes) and b"dmarc" in raw_content.lower(): + return True + elif isinstance(raw_content, str) and "dmarc" in raw_content.lower(): + return True + + return False - if isinstance(raw_content, bytes) and b"dmarc" in raw_content.lower(): - logger.warning("DMARC alert detected, skipping") - return None - elif isinstance(raw_content, str) and "dmarc" in raw_content.lower(): - logger.warning("DMARC alert detected, skipping") - return None - - if not name or not message: - raise Exception( - "Could not create alert from email when name or message is missing." - ) + @staticmethod + def _is_spf_report(event: dict) -> bool: + """ + Detect SPF failure reports. + + Args: + event: Email event data + + Returns: + bool: True if email is an SPF report + """ + subject = event.get("subject", "").lower() + sender = event.get("from", "").lower() + + spf_patterns = ["spf fail", "spf failure", "spf report", "sender policy framework"] + return any(pattern in subject or pattern in sender for pattern in spf_patterns) - try: - timestamp = datetime.datetime.fromtimestamp( - float(event["timestamp"]) - ).isoformat() - except Exception: - timestamp = datetime.datetime.now().isoformat() - # default values - severity = "info" - status = "firing" - - # clean redundant - event.pop("signature", "") - event.pop("token", "") - - logger.info("Basic formatting done") - - alert = AlertDto( - name=name, - source=[source], - message=message, - description=message, - lastReceived=timestamp, - severity=severity, - status=status, - raw_email={**event}, + @staticmethod + def _is_bounce_notification(event: dict) -> bool: + """ + Detect bounce notifications. + + Args: + event: Email event data + + Returns: + bool: True if email is a bounce notification + """ + sender = event.get("from", "").lower() + subject = event.get("subject", "").lower() + + bounce_senders = ["mailer-daemon@", "postmaster@", "bounce@"] + bounce_patterns = ["delivery failed", "returned mail", "undelivered mail", "mail delivery"] + + return ( + any(bounce_sender in sender for bounce_sender in bounce_senders) or + any(pattern in subject for pattern in bounce_patterns) ) - # now I want to add all attributes from raw_email to the alert dto, except the ones that are already set - for key, value in event.items(): - # avoid "-" in keys cuz CEL will failed [stripped-text screw CEL] - if not hasattr(alert, key) and "-" not in key: - setattr(alert, key, value) + @staticmethod + def _classify_email_type(event: dict) -> str: + """ + Classify email type for appropriate handling. + + Args: + event: Email event data + + Returns: + str: Email type (dmarc_report, spf_report, bounce, alert) + """ + if MailgunProvider._is_dmarc_report(event): + return "dmarc_report" + elif MailgunProvider._is_spf_report(event): + return "spf_report" + elif MailgunProvider._is_bounce_notification(event): + return "bounce" + else: + return "alert" + + @staticmethod + def _describe_attachments(event: dict) -> str: + """ + Create a description of email attachments. + + Args: + event: Email event data + + Returns: + str: Description of attachments + """ + attachment_count = event.get("attachment-count", "0") + attachments = [] + + # Try to get attachment info + for i in range(1, int(attachment_count) + 1): + attachment_key = f"attachment-{i}" + if attachment_key in event: + attachment_info = str(event[attachment_key]) + # Extract filename if possible + if "filename=" in attachment_info: + filename = attachment_info.split("filename=")[1].split(",")[0].strip("'\"") + attachments.append(filename) + + if attachments: + return f"{attachment_count} attachment(s): {', '.join(attachments)}" + return f"{attachment_count} attachment(s)" + + @staticmethod + def _extract_message_content(event: dict, email_type: str) -> str: + """ + Extract message content based on email type. + + Args: + event: Email event data + email_type: Type of email (dmarc_report, spf_report, bounce, alert) + + Returns: + str: Extracted message content + """ + # Try standard body fields first + message = event.get("stripped-text") or event.get("Body-plain") + + if message: + return message + + # For DMARC reports, use subject as message + if email_type == "dmarc_report": + subject = event.get("subject", "") + if subject: + return f"DMARC Report: {subject}" + + # For emails with attachments, describe the attachment + if event.get("attachment-count") and int(event.get("attachment-count", 0)) > 0: + attachment_info = MailgunProvider._describe_attachments(event) + subject = event.get("subject", "") + if subject: + return f"{subject} ({attachment_info})" + return f"Email with {attachment_info}" + + # Fallback to subject + subject = event.get("subject", "") + if subject: + return subject + + return "No message content available" + @staticmethod + def _log_email_processing(event: dict, email_type: str, action: str): + """ + Enhanced logging for email processing. + + Args: + event: Email event data + email_type: Type of email + action: Action taken (skipped, processed, etc.) + """ logger.info( - "Alert formatted", + f"Email processing: {action}", + extra={ + "email_type": email_type, + "from": event.get("from"), + "subject": event.get("subject"), + "has_body": bool(event.get("Body-plain") or event.get("stripped-text")), + "has_attachments": bool(event.get("attachment-count")), + "content_type": event.get("Content-Type"), + "action": action + } ) - if provider_instance: + @staticmethod + def _format_alert( + event: dict, provider_instance: "MailgunProvider" = None + ) -> AlertDto: + """ + Format email event into an AlertDto. + + Args: + event: Email event data + provider_instance: Optional MailgunProvider instance for config access + + Returns: + AlertDto or None if email should be skipped + """ + try: + # We receive FormData here, convert it to simple dict. logger.info( - "Provider instance found", + "Received alert from mail", + extra={ + "from": event.get("from"), + "subject": event.get("subject") + }, ) - extraction_rules = provider_instance.authentication_config.extraction - if extraction_rules: - logger.info( - "Extraction rules found", + event = dict(event) + + # Classify email type first + email_type = MailgunProvider._classify_email_type(event) + + # Check provider instance configuration for skip settings + skip_dmarc = True # Default + skip_spf = True # Default + handle_no_body = True # Default + + if provider_instance and hasattr(provider_instance, 'authentication_config'): + skip_dmarc = getattr(provider_instance.authentication_config, 'skip_dmarc_reports', True) + skip_spf = getattr(provider_instance.authentication_config, 'skip_spf_reports', True) + handle_no_body = getattr(provider_instance.authentication_config, 'handle_emails_without_body', True) + + # Handle DMARC reports + if email_type == "dmarc_report" and skip_dmarc: + MailgunProvider._log_email_processing(event, email_type, "skipped (DMARC report)") + return None + + # Handle SPF reports + if email_type == "spf_report" and skip_spf: + MailgunProvider._log_email_processing(event, email_type, "skipped (SPF report)") + return None + + # Handle bounce notifications (optionally skip) + if email_type == "bounce": + MailgunProvider._log_email_processing(event, email_type, "processing (bounce notification)") + + # Extract basic fields + source = event.get("from", "unknown@unknown.com") + name = event.get("subject", source) + + # Extract message content with fallback logic + message = MailgunProvider._extract_message_content(event, email_type) + + # Validate required fields with flexible handling + if not name: + name = source or "Unknown Email" + logger.warning( + "Email has no subject, using source as name", + extra={"from": source, "email_type": email_type} ) - for rule in extraction_rules: - key = rule.get("key") - regex = rule.get("value") - if key in dict(event): - try: - match = re.search(regex, event[key]) - if match: - for ( - group_name, - group_value, - ) in match.groupdict().items(): - setattr(alert, group_name, group_value) - except Exception as e: - logger.exception( - f"Error extracting key {key} with regex {regex}: {e}", - extra={ - "provider_id": provider_instance.provider_id, - "tenant_id": provider_instance.context_manager.tenant_id, - }, - ) - logger.info( - "Alert extracted", - ) - return alert + + if not message: + if handle_no_body: + message = f"Email from {source} (no body content)" + logger.warning( + "Email has no body content, using fallback message", + extra={"from": source, "subject": name, "email_type": email_type} + ) + else: + MailgunProvider._log_email_processing(event, email_type, "skipped (no body content)") + return None + + # Extract timestamp + try: + timestamp = datetime.datetime.fromtimestamp( + float(event["timestamp"]) + ).isoformat() + except Exception: + timestamp = datetime.datetime.now().isoformat() + + # Default values + severity = "info" + status = "firing" + + # Clean redundant fields + event.pop("signature", None) + event.pop("token", None) + + MailgunProvider._log_email_processing(event, email_type, "processing") + + # Create alert + alert = AlertDto( + name=name, + source=[source], + message=message, + description=message, + lastReceived=timestamp, + severity=severity, + status=status, + raw_email={**event}, + ) + + # Add all attributes from raw_email to the alert dto, except the ones that are already set + for key, value in event.items(): + # Avoid "-" in keys cuz CEL will fail [stripped-text screw CEL] + if not hasattr(alert, key) and "-" not in key: + setattr(alert, key, value) + + # Add email type as metadata + setattr(alert, "email_type", email_type) + + logger.info("Alert formatted", extra={"email_type": email_type, "name": name}) + + # Apply extraction rules if configured + if provider_instance: + logger.info("Provider instance found") + extraction_rules = provider_instance.authentication_config.extraction + if extraction_rules: + logger.info("Extraction rules found") + for rule in extraction_rules: + key = rule.get("key") + regex = rule.get("value") + if key in dict(event): + try: + match = re.search(regex, event[key]) + if match: + for ( + group_name, + group_value, + ) in match.groupdict().items(): + setattr(alert, group_name, group_value) + except Exception as e: + logger.exception( + f"Error extracting key {key} with regex {regex}: {e}", + extra={ + "provider_id": provider_instance.provider_id, + "tenant_id": provider_instance.context_manager.tenant_id, + }, + ) + + logger.info("Alert extracted successfully", extra={"email_type": email_type}) + return alert + + except KeyError as e: + logger.error( + f"Missing required field in email event: {e}", + extra={ + "event_keys": list(event.keys()), + "missing_field": str(e) + } + ) + raise + except Exception as e: + logger.error( + f"Error formatting alert from email: {e}", + extra={ + "event_keys": list(event.keys()), + "from": event.get("from"), + "subject": event.get("subject"), + "error": str(e) + } + ) + raise if __name__ == "__main__": From 075f72d4c7a606da059df11cdae146c4932e1912 Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 11:03:52 +0300 Subject: [PATCH 02/12] feat(alerts): Add reprocess functionality for error alerts Backend: - Add get_error_alerts_to_reprocess() helper function to db.py - Add dismiss_error_alert_by_id() helper function to db.py - Add POST /alerts/event/error/reprocess API endpoint - Support reprocessing single alert or all error alerts - Auto-dismiss successfully reprocessed alerts - Return detailed results with success/failure counts Frontend: - Add reprocessErrorAlerts() function to useAlerts hook - Add reprocess buttons to AlertErrorEventModal UI - Add handleReprocessSelected() and handleReprocessAll() handlers - Add loading states and toast notifications - Disable buttons during operations to prevent race conditions This allows users to reprocess failed alert events after code fixes (e.g., DMARC detection improvements). Successfully reprocessed alerts are automatically dismissed from the error alerts list. --- keep-ui/entities/alerts/model/useAlerts.ts | 18 +++ .../ui/alert-error-event-modal.tsx | 80 +++++++++++- keep/api/core/db.py | 61 +++++++++ keep/api/routes/alerts.py | 120 ++++++++++++++++++ 4 files changed, 276 insertions(+), 3 deletions(-) diff --git a/keep-ui/entities/alerts/model/useAlerts.ts b/keep-ui/entities/alerts/model/useAlerts.ts index 9fa0a2dd96..111db3195b 100644 --- a/keep-ui/entities/alerts/model/useAlerts.ts +++ b/keep-ui/entities/alerts/model/useAlerts.ts @@ -134,12 +134,30 @@ export const useAlerts = () => { } }; + // Function to reprocess error alerts with updated provider code + // If alertId is provided, reprocesses that specific alert + // If no alertId is provided, reprocesses all error alerts + const reprocessErrorAlerts = async (alertId?: string) => { + if (!api.isReady()) return { success: false, message: "API not ready" }; + + try { + const payload = alertId ? { alert_id: alertId } : {}; + const result = await api.post(`/alerts/event/error/reprocess`, payload); + await mutate(); // Refresh the data + return { success: true, ...result }; + } catch (error) { + console.error("Failed to reprocess error alert(s):", error); + return { success: false, message: "Failed to reprocess" }; + } + }; + return { data, error, isLoading, mutate, dismissErrorAlerts, + reprocessErrorAlerts, }; }; diff --git a/keep-ui/features/alerts/alert-error-event-process/ui/alert-error-event-modal.tsx b/keep-ui/features/alerts/alert-error-event-process/ui/alert-error-event-modal.tsx index 642ef2086d..19580bfb4a 100644 --- a/keep-ui/features/alerts/alert-error-event-process/ui/alert-error-event-modal.tsx +++ b/keep-ui/features/alerts/alert-error-event-process/ui/alert-error-event-modal.tsx @@ -13,6 +13,7 @@ import { Button, } from "@tremor/react"; import { DynamicImageProviderIcon } from "@/components/ui/DynamicProviderIcon"; +import { toast } from "react-toastify"; interface ErrorAlert { id: string; @@ -32,9 +33,10 @@ export const AlertErrorEventModal: React.FC = ({ onClose, }) => { const { useErrorAlerts } = useAlerts(); - const { data: errorAlerts, dismissErrorAlerts } = useErrorAlerts(); + const { data: errorAlerts, dismissErrorAlerts, reprocessErrorAlerts } = useErrorAlerts(); const [selectedAlertId, setSelectedAlertId] = useState(""); const [isDismissing, setIsDismissing] = useState(false); + const [isReprocessing, setIsReprocessing] = useState(false); // Set the first alert as selected when data loads or changes React.useEffect(() => { @@ -96,6 +98,61 @@ export const AlertErrorEventModal: React.FC = ({ } }; + const handleReprocessSelected = async () => { + if (selectedAlert) { + setIsReprocessing(true); + try { + const result = await reprocessErrorAlerts(selectedAlert.id); + if (result.success) { + toast.success( + `Reprocessed successfully! ${result.message || ""}`, + { position: "top-right" } + ); + + // Handle navigation after successful reprocessing + if (errorAlerts?.length === 1) { + setSelectedAlertId(""); + onClose(); + } else if (parseInt(selectedAlertId, 10) === errorAlerts.length - 1) { + setSelectedAlertId((parseInt(selectedAlertId, 10) - 1).toString()); + } + } else { + toast.error(`Reprocessing failed: ${result.message}`, { + position: "top-right", + }); + } + } catch (error) { + console.error("Failed to reprocess alert:", error); + toast.error("Failed to reprocess alert", { position: "top-right" }); + } finally { + setIsReprocessing(false); + } + } + }; + + const handleReprocessAll = async () => { + setIsReprocessing(true); + try { + const result = await reprocessErrorAlerts(); + if (result.success) { + toast.success( + `Reprocessed ${result.successful || 0} alert(s) successfully!`, + { position: "top-right" } + ); + onClose(); + } else { + toast.error(`Reprocessing failed: ${result.message}`, { + position: "top-right", + }); + } + } catch (error) { + console.error("Failed to reprocess alerts:", error); + toast.error("Failed to reprocess alerts", { position: "top-right" }); + } finally { + setIsReprocessing(false); + } + }; + return ( = ({
+ + @@ -166,7 +240,7 @@ export const AlertErrorEventModal: React.FC = ({ color="orange" variant="secondary" onClick={handleDismissAll} - disabled={isDismissing} + disabled={isDismissing || isReprocessing} > {isDismissing ? "Dismissing..." : "Dismiss All"} diff --git a/keep/api/core/db.py b/keep/api/core/db.py index e2efea2f2b..d8e5a8382c 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -5855,6 +5855,67 @@ def get_error_alerts(tenant_id: str, limit: int = 100) -> List[AlertRaw]: ) +def get_error_alerts_to_reprocess( + tenant_id: str, alert_id: str | None = None +) -> List[AlertRaw]: + """ + Get error alerts to reprocess. + + Args: + tenant_id: Tenant ID + alert_id: Optional specific alert ID to reprocess + + Returns: + List of AlertRaw objects to reprocess + """ + with Session(engine) as session: + query = session.query(AlertRaw).filter( + AlertRaw.tenant_id == tenant_id, + AlertRaw.error == True, + AlertRaw.dismissed == False, + ) + + if alert_id: + if isinstance(alert_id, str): + alert_id_uuid = uuid.UUID(alert_id) + else: + alert_id_uuid = alert_id + query = query.filter(AlertRaw.id == alert_id_uuid) + + return query.all() + + +def dismiss_error_alert_by_id(tenant_id: str, alert_id: str, dismissed_by: str | None = None) -> None: + """ + Dismiss a specific error alert after successful reprocessing. + + Args: + tenant_id: Tenant ID + alert_id: Alert ID to dismiss + dismissed_by: Optional user who dismissed the alert + """ + with Session(engine) as session: + if isinstance(alert_id, str): + alert_id_uuid = uuid.UUID(alert_id) + else: + alert_id_uuid = alert_id + + stmt = ( + update(AlertRaw) + .where( + AlertRaw.id == alert_id_uuid, + AlertRaw.tenant_id == tenant_id, + ) + .values( + dismissed=True, + dismissed_by=dismissed_by, + dismissed_at=datetime.now(tz=timezone.utc), + ) + ) + session.execute(stmt) + session.commit() + + def dismiss_error_alerts(tenant_id: str, alert_id=None, dismissed_by=None) -> None: with Session(engine) as session: stmt = ( diff --git a/keep/api/routes/alerts.py b/keep/api/routes/alerts.py index 2adaa090dc..b8f0117e4d 100644 --- a/keep/api/routes/alerts.py +++ b/keep/api/routes/alerts.py @@ -30,6 +30,7 @@ from keep.api.core.cel_to_sql.sql_providers.base import CelToSqlException from keep.api.core.config import config from keep.api.core.db import dismiss_error_alerts as dismiss_error_alerts_db +from keep.api.core.db import dismiss_error_alert_by_id from keep.api.core.db import enrich_alerts_with_incidents from keep.api.core.db import get_alert_audit as get_alert_audit_db from keep.api.core.db import ( @@ -39,6 +40,7 @@ get_enrichment, ) from keep.api.core.db import get_error_alerts as get_error_alerts_db +from keep.api.core.db import get_error_alerts_to_reprocess from keep.api.core.db import ( get_last_alerts, get_last_alerts_by_fingerprints, @@ -1459,3 +1461,121 @@ def dismiss_error_alerts( ) return {"success": True, "message": "Successfully dismissed all alerts"} + + +@router.post( + "/event/error/reprocess", + description="Reprocess error alerts with updated provider code. If alert_id is provided, reprocesses that specific alert. If no alert_id is provided, reprocesses all error alerts.", +) +def reprocess_error_alerts( + request: DismissAlertRequest = None, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["write:alert"]) + ), +) -> dict: + """ + Reprocess failed events with current provider code. + If alert_id is provided, reprocesses that specific alert. + If no alert_id is provided, reprocesses all error alerts. + """ + tenant_id = authenticated_entity.tenant_id + alert_id = request.alert_id if request else None + + logger.info( + "Reprocessing error alerts", + extra={ + "tenant_id": tenant_id, + "alert_id": alert_id, + }, + ) + + # Get error alerts to reprocess + error_alerts = get_error_alerts_to_reprocess(tenant_id, alert_id) + + if not error_alerts: + logger.info( + "No error alerts found to reprocess", + extra={"tenant_id": tenant_id, "alert_id": alert_id}, + ) + return {"success": True, "message": "No error alerts found to reprocess", "successful": 0, "failed": 0, "total": 0} + + successful = 0 + failed = 0 + failed_alerts = [] + + for error_alert in error_alerts: + try: + logger.info( + "Attempting to reprocess error alert", + extra={ + "tenant_id": tenant_id, + "alert_id": str(error_alert.id), + "provider_type": error_alert.provider_type, + }, + ) + + # Attempt to reprocess the event + process_event( + ctx={}, # No arq context for manual reprocessing + tenant_id=tenant_id, + provider_type=error_alert.provider_type, + provider_id=None, + fingerprint=None, + api_key_name=None, + trace_id=None, + event=error_alert.raw_alert, + notify_client=True, + ) + + # If successful, mark the error alert as dismissed + dismiss_error_alert_by_id( + tenant_id, + str(error_alert.id), + dismissed_by=authenticated_entity.email + ) + successful += 1 + + logger.info( + "Successfully reprocessed error alert", + extra={ + "tenant_id": tenant_id, + "alert_id": str(error_alert.id), + }, + ) + + except Exception as e: + logger.error( + f"Failed to reprocess error alert: {e}", + extra={ + "tenant_id": tenant_id, + "alert_id": str(error_alert.id), + "error": str(e), + }, + ) + failed += 1 + failed_alerts.append( + {"alert_id": str(error_alert.id), "error": str(e)} + ) + + logger.info( + "Reprocessing completed", + extra={ + "tenant_id": tenant_id, + "successful": successful, + "failed": failed, + "total": len(error_alerts), + }, + ) + + response = { + "success": successful > 0, + "message": f"Reprocessed {successful} alert(s) successfully, {failed} failed", + "successful": successful, + "failed": failed, + "total": len(error_alerts), + } + + if failed_alerts: + response["failed_alerts"] = failed_alerts + + return response From ab8bf07dba93c6e278236f109bb5eda0def33ab2 Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 11:14:57 +0300 Subject: [PATCH 03/12] fix(mailgun): Fix KeyError in logging - cannot use name in extra dict --- keep/providers/mailgun_provider/mailgun_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keep/providers/mailgun_provider/mailgun_provider.py b/keep/providers/mailgun_provider/mailgun_provider.py index 3fe39f0137..4ea664c75d 100644 --- a/keep/providers/mailgun_provider/mailgun_provider.py +++ b/keep/providers/mailgun_provider/mailgun_provider.py @@ -575,7 +575,7 @@ def _format_alert( # Add email type as metadata setattr(alert, "email_type", email_type) - logger.info("Alert formatted", extra={"email_type": email_type, "name": name}) + logger.info("Alert formatted", extra={"email_type": email_type, "alert_name": name}) # Apply extraction rules if configured if provider_instance: From 095aa07ca24a07c9ea2f82ff63d5305e61a82efd Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 11:17:23 +0300 Subject: [PATCH 04/12] fix(mailgun): Change default to NOT skip DMARC/SPF reports Changed skip_dmarc_reports and skip_spf_reports defaults from True to False. DMARC and SPF reports will now create alerts by default. Users can still enable skipping via UI configuration if desired. DMARC reports without body will get message: DMARC Report: {subject} + attachment info --- keep/providers/mailgun_provider/mailgun_provider.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/keep/providers/mailgun_provider/mailgun_provider.py b/keep/providers/mailgun_provider/mailgun_provider.py index 4ea664c75d..5400bc4cd7 100644 --- a/keep/providers/mailgun_provider/mailgun_provider.py +++ b/keep/providers/mailgun_provider/mailgun_provider.py @@ -56,20 +56,20 @@ class MailgunProviderAuthConfig: }, ) skip_dmarc_reports: bool = dataclasses.field( - default=True, + default=False, metadata={ "required": False, "description": "Skip DMARC reports", - "hint": "Automatically skip DMARC aggregate reports", + "hint": "Enable to automatically skip DMARC aggregate reports (not recommended)", "type": "switch", }, ) skip_spf_reports: bool = dataclasses.field( - default=True, + default=False, metadata={ "required": False, "description": "Skip SPF reports", - "hint": "Automatically skip SPF failure reports", + "hint": "Enable to automatically skip SPF failure reports (not recommended)", "type": "switch", }, ) From a929d47173a6991c9cb0d428ba86fa41889663c3 Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 11:30:42 +0300 Subject: [PATCH 05/12] feat(mailgun): Add intelligent severity extraction from email content - Add _extract_severity_from_email() method for keyword-based severity detection - Detect critical, high, warning, low, and info severity from subject/body - Assign severity based on email type (DMARC=low, SPF/bounce=warning) - Priority keyword matching: critical > high > warning > low > info Examples: - DMARC reports: low severity (informational) - [SUCCESS] emails: low severity - [ERROR]/[CRITICAL] emails: high/critical severity - [WARNING] emails: warning severity This provides better alert prioritization in the UI with appropriate visual indicators. --- .../mailgun_provider/mailgun_provider.py | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/keep/providers/mailgun_provider/mailgun_provider.py b/keep/providers/mailgun_provider/mailgun_provider.py index 5400bc4cd7..1302b2f298 100644 --- a/keep/providers/mailgun_provider/mailgun_provider.py +++ b/keep/providers/mailgun_provider/mailgun_provider.py @@ -435,6 +435,54 @@ def _extract_message_content(event: dict, email_type: str) -> str: return "No message content available" + @staticmethod + def _extract_severity_from_email(event: dict, email_type: str) -> str: + """ + Extract severity from email content and type. + + Args: + event: Email event data + email_type: Type of email (dmarc_report, spf_report, bounce, alert) + + Returns: + str: Severity level (critical, high, warning, low, info) + """ + # Get subject and body for keyword analysis + subject = (event.get("subject") or "").lower() + body = (event.get("stripped-text") or event.get("Body-plain") or "").lower() + combined_text = f"{subject} {body}" + + # Critical keywords + critical_keywords = ["critical", "emergency", "fatal", "disaster", "down", "outage", "failed"] + if any(keyword in combined_text for keyword in critical_keywords): + return "critical" + + # High/Error keywords + high_keywords = ["error", "high", "urgent", "failure", "exception", "alert"] + if any(keyword in combined_text for keyword in high_keywords): + return "high" + + # Warning keywords + warning_keywords = ["warning", "warn", "caution", "attention", "degraded"] + if any(keyword in combined_text for keyword in warning_keywords): + return "warning" + + # Success/OK keywords (low severity) + success_keywords = ["success", "successful", "completed", "ok", "healthy", "recovered"] + if any(keyword in combined_text for keyword in success_keywords): + return "low" + + # Email type based severity + if email_type == "dmarc_report": + return "low" # DMARC reports are informational + elif email_type == "spf_report": + return "warning" # SPF failures are warnings + elif email_type == "bounce": + return "warning" # Bounces are warnings + + # Default + return "info" + @staticmethod def _log_email_processing(event: dict, email_type: str, action: str): """ @@ -544,8 +592,8 @@ def _format_alert( except Exception: timestamp = datetime.datetime.now().isoformat() - # Default values - severity = "info" + # Extract severity from email content and type + severity = MailgunProvider._extract_severity_from_email(event, email_type) status = "firing" # Clean redundant fields From 7dc17711ecfc833db9e4f759209061da84bad730 Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 11:33:36 +0300 Subject: [PATCH 06/12] feat(mailgun): Add intelligent status extraction from email content - Add _extract_status_from_email() method for keyword-based status detection - Detect resolved, acknowledged, and firing status from subject/body - Support status transitions via email (e.g., resolved notifications) Status mapping: - resolved: resolved, cleared, recovered, fixed, closed, ok now, back to normal - acknowledged: acknowledged, ack, investigating, working on - firing: default for new alerts This allows email alerts to properly reflect their lifecycle status. --- .../mailgun_provider/mailgun_provider.py | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/keep/providers/mailgun_provider/mailgun_provider.py b/keep/providers/mailgun_provider/mailgun_provider.py index 1302b2f298..2971fadee4 100644 --- a/keep/providers/mailgun_provider/mailgun_provider.py +++ b/keep/providers/mailgun_provider/mailgun_provider.py @@ -483,6 +483,35 @@ def _extract_severity_from_email(event: dict, email_type: str) -> str: # Default return "info" + @staticmethod + def _extract_status_from_email(event: dict) -> str: + """ + Extract status from email content. + + Args: + event: Email event data + + Returns: + str: Status (firing, resolved, acknowledged) + """ + # Get subject and body for keyword analysis + subject = (event.get("subject") or "").lower() + body = (event.get("stripped-text") or event.get("Body-plain") or "").lower() + combined_text = f"{subject} {body}" + + # Resolved keywords + resolved_keywords = ["resolved", "cleared", "recovered", "fixed", "closed", "ok now", "back to normal", "restoration"] + if any(keyword in combined_text for keyword in resolved_keywords): + return "resolved" + + # Acknowledged keywords + acknowledged_keywords = ["acknowledged", "ack", "investigating", "working on"] + if any(keyword in combined_text for keyword in acknowledged_keywords): + return "acknowledged" + + # Default to firing for new alerts + return "firing" + @staticmethod def _log_email_processing(event: dict, email_type: str, action: str): """ @@ -592,9 +621,9 @@ def _format_alert( except Exception: timestamp = datetime.datetime.now().isoformat() - # Extract severity from email content and type + # Extract severity and status from email content and type severity = MailgunProvider._extract_severity_from_email(event, email_type) - status = "firing" + status = MailgunProvider._extract_status_from_email(event) # Clean redundant fields event.pop("signature", None) From 648587562e44023078c84b5e9149d1405507deee Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 11:37:10 +0300 Subject: [PATCH 07/12] fix(mailgun): Fix source field to use provider name instead of email sender Changed source field from email sender address to proper provider source format: - Primary source: mailgun (for source facet filtering) - Secondary source: email sender address (for detailed tracking) This fixes the source counter in alerts feed and allows proper filtering by source=mailgun. Before: source = [noreply-dmarc-support@google.com] After: source = [mailgun, noreply-dmarc-support@google.com] --- .../mailgun_provider/mailgun_provider.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/keep/providers/mailgun_provider/mailgun_provider.py b/keep/providers/mailgun_provider/mailgun_provider.py index 2971fadee4..d35f03506c 100644 --- a/keep/providers/mailgun_provider/mailgun_provider.py +++ b/keep/providers/mailgun_provider/mailgun_provider.py @@ -588,26 +588,31 @@ def _format_alert( MailgunProvider._log_email_processing(event, email_type, "processing (bounce notification)") # Extract basic fields - source = event.get("from", "unknown@unknown.com") - name = event.get("subject", source) + email_from = event.get("from", "unknown@unknown.com") + name = event.get("subject", email_from) + + # Build source list: primary is "mailgun", secondary is email sender + source = ["mailgun"] + if email_from and email_from != "unknown@unknown.com": + source.append(email_from) # Extract message content with fallback logic message = MailgunProvider._extract_message_content(event, email_type) # Validate required fields with flexible handling if not name: - name = source or "Unknown Email" + name = email_from or "Unknown Email" logger.warning( - "Email has no subject, using source as name", - extra={"from": source, "email_type": email_type} + "Email has no subject, using sender as name", + extra={"from": email_from, "email_type": email_type} ) if not message: if handle_no_body: - message = f"Email from {source} (no body content)" + message = f"Email from {email_from} (no body content)" logger.warning( "Email has no body content, using fallback message", - extra={"from": source, "subject": name, "email_type": email_type} + extra={"from": email_from, "subject": name, "email_type": email_type} ) else: MailgunProvider._log_email_processing(event, email_type, "skipped (no body content)") From d9ec835f71df628d254c315c718b8b860e7d6423 Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 11:40:51 +0300 Subject: [PATCH 08/12] revert: Restore original source field behavior (email sender) Reverted the source field change. After review, the original behavior is correct: - source = [email_sender] allows filtering by specific email senders - This is the intended behavior for email-based providers - Users can filter by source to see alerts from specific monitoring systems The source counter showing individual email addresses is intentional. Users can use the email_type field to filter by provider (e.g., email_type=dmarc_report). --- .../mailgun_provider/mailgun_provider.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/keep/providers/mailgun_provider/mailgun_provider.py b/keep/providers/mailgun_provider/mailgun_provider.py index d35f03506c..2971fadee4 100644 --- a/keep/providers/mailgun_provider/mailgun_provider.py +++ b/keep/providers/mailgun_provider/mailgun_provider.py @@ -588,31 +588,26 @@ def _format_alert( MailgunProvider._log_email_processing(event, email_type, "processing (bounce notification)") # Extract basic fields - email_from = event.get("from", "unknown@unknown.com") - name = event.get("subject", email_from) - - # Build source list: primary is "mailgun", secondary is email sender - source = ["mailgun"] - if email_from and email_from != "unknown@unknown.com": - source.append(email_from) + source = event.get("from", "unknown@unknown.com") + name = event.get("subject", source) # Extract message content with fallback logic message = MailgunProvider._extract_message_content(event, email_type) # Validate required fields with flexible handling if not name: - name = email_from or "Unknown Email" + name = source or "Unknown Email" logger.warning( - "Email has no subject, using sender as name", - extra={"from": email_from, "email_type": email_type} + "Email has no subject, using source as name", + extra={"from": source, "email_type": email_type} ) if not message: if handle_no_body: - message = f"Email from {email_from} (no body content)" + message = f"Email from {source} (no body content)" logger.warning( "Email has no body content, using fallback message", - extra={"from": email_from, "subject": name, "email_type": email_type} + extra={"from": source, "subject": name, "email_type": email_type} ) else: MailgunProvider._log_email_processing(event, email_type, "skipped (no body content)") From 17de391872b9422414b998ea336e95ec8a18ac0c Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 11:52:44 +0300 Subject: [PATCH 09/12] feat(scripts): Add script to update Mailgun alert metadata Add database script to refresh severity and status for Mailgun alerts that were processed before the intelligent extraction logic was added. Features: - Updates severity using keyword-based detection - Updates status using keyword-based detection - Adds email_type classification if missing - Dry-run mode by default (safe) - Configurable time range (default: 30 days) - Detailed reporting of changes - Error handling for individual alerts Usage: # Dry run (see what would change) python scripts/update_mailgun_alert_metadata.py --tenant-id keep # Actually update python scripts/update_mailgun_alert_metadata.py --tenant-id keep --apply # Check last 7 days only python scripts/update_mailgun_alert_metadata.py --tenant-id keep --days 7 --apply This allows retroactive updates for alerts processed before severity/status extraction improvements were added. --- scripts/update_mailgun_alert_metadata.py | 221 +++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 scripts/update_mailgun_alert_metadata.py diff --git a/scripts/update_mailgun_alert_metadata.py b/scripts/update_mailgun_alert_metadata.py new file mode 100644 index 0000000000..539279dcac --- /dev/null +++ b/scripts/update_mailgun_alert_metadata.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" +Update severity and status for Mailgun alerts that were processed without +the intelligent extraction logic. + +This script identifies Mailgun alerts with default severity/status values +and updates them using the new intelligent extraction methods. +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from keep.api.core.db import get_session_sync +from keep.api.models.db.alert import Alert +from keep.providers.mailgun_provider.mailgun_provider import MailgunProvider +from sqlalchemy import and_, or_ +from datetime import datetime, timedelta + + +def update_mailgun_alerts(tenant_id: str, dry_run: bool = True, days_back: int = 30): + """ + Update severity and status for Mailgun alerts. + + Args: + tenant_id: Tenant ID to update alerts for + dry_run: If True, only show what would be updated + days_back: How many days back to check (default: 30) + """ + session = get_session_sync() + + try: + # Calculate cutoff date + cutoff_date = datetime.utcnow() - timedelta(days=days_back) + + print(f"Searching for Mailgun alerts from the last {days_back} days...") + print(f"Tenant ID: {tenant_id}") + print(f"Cutoff date: {cutoff_date}") + print() + + # Get Mailgun alerts that likely need updating + # Focus on alerts with default severity="info" which might be wrong + alerts = session.query(Alert).filter( + and_( + Alert.tenant_id == tenant_id, + Alert.provider_type == "mailgun", + Alert.timestamp >= cutoff_date, + ) + ).all() + + print(f"Found {len(alerts)} total Mailgun alerts") + print() + + updated_count = 0 + skipped_count = 0 + error_count = 0 + + # Group by severity for reporting + severity_changes = {} + status_changes = {} + + for alert in alerts: + try: + # Get current values + event = alert.event + current_severity = event.get("severity", "info") + current_status = event.get("status", "firing") + alert_name = event.get("name", "Unknown")[:80] + + # Build pseudo-event for extraction + email_type = event.get("email_type", "alert") + + # If no email_type, try to classify from available data + if not email_type or email_type == "alert": + # Get source (email sender) + source_list = event.get("source", []) + email_from = source_list[0] if isinstance(source_list, list) and len(source_list) > 0 else "" + + pseudo_event = { + "from": email_from, + "subject": event.get("name", ""), + "stripped-text": event.get("message", ""), + "Body-plain": event.get("description", ""), + "Content-Type": event.get("Content-Type", ""), + } + email_type = MailgunProvider._classify_email_type(pseudo_event) + + # Extract severity and status using new logic + extraction_event = { + "subject": event.get("name", ""), + "stripped-text": event.get("message", ""), + "Body-plain": event.get("description", ""), + } + + new_severity = MailgunProvider._extract_severity_from_email( + extraction_event, + email_type + ) + new_status = MailgunProvider._extract_status_from_email( + extraction_event + ) + + # Check if update is needed + needs_update = ( + current_severity != new_severity or + current_status != new_status or + not event.get("email_type") + ) + + if needs_update: + print(f"Alert: {alert_name}") + + if current_severity != new_severity: + print(f" Severity: {current_severity} → {new_severity}") + severity_changes[f"{current_severity}→{new_severity}"] = \ + severity_changes.get(f"{current_severity}→{new_severity}", 0) + 1 + + if current_status != new_status: + print(f" Status: {current_status} → {new_status}") + status_changes[f"{current_status}→{new_status}"] = \ + status_changes.get(f"{current_status}→{new_status}", 0) + 1 + + if not event.get("email_type"): + print(f" Email Type: (none) → {email_type}") + + print(f" Timestamp: {alert.timestamp}") + print() + + if not dry_run: + # Update the event dict + event["severity"] = new_severity + event["status"] = new_status + if not event.get("email_type"): + event["email_type"] = email_type + + # Update in database + alert.event = event + session.add(alert) + updated_count += 1 + else: + skipped_count += 1 + + except Exception as e: + print(f"❌ Error processing alert {alert.id}: {e}") + error_count += 1 + continue + + # Commit if not dry run + if not dry_run and updated_count > 0: + session.commit() + print(f"\n✅ Successfully updated {updated_count} alerts") + elif dry_run and updated_count > 0: + print(f"\n📋 DRY RUN: Would update {updated_count} alerts") + print("Run with --apply to actually update the database") + else: + print(f"\n✅ No alerts need updating") + + # Print summary + print(f"\nSummary:") + print(f" Total alerts checked: {len(alerts)}") + print(f" Need updating: {updated_count}") + print(f" Already correct: {skipped_count}") + print(f" Errors: {error_count}") + + if severity_changes: + print(f"\nSeverity changes:") + for change, count in severity_changes.items(): + print(f" {change}: {count} alerts") + + if status_changes: + print(f"\nStatus changes:") + for change, count in status_changes.items(): + print(f" {change}: {count} alerts") + + finally: + session.close() + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Update severity and status for Mailgun alerts" + ) + parser.add_argument( + "--tenant-id", + default="keep", + help="Tenant ID (default: keep)" + ) + parser.add_argument( + "--apply", + action="store_true", + help="Actually update the database (default is dry-run)" + ) + parser.add_argument( + "--days", + type=int, + default=30, + help="How many days back to check (default: 30)" + ) + + args = parser.parse_args() + + print("=" * 70) + print("Mailgun Alert Metadata Update Script") + print("=" * 70) + print() + + if args.apply: + print("⚠️ APPLY MODE: Will update the database") + else: + print("📋 DRY RUN MODE: Will only show what would change") + + print() + + update_mailgun_alerts( + tenant_id=args.tenant_id, + dry_run=not args.apply, + days_back=args.days + ) + From d847cb27e2d2c46d6cf262a176c63ba1d1aed253 Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 11:56:03 +0300 Subject: [PATCH 10/12] revert: Remove intelligent severity/status extraction, keep hardcoded values Reverted back to original hardcoded severity and status values: - severity = info (hardcoded) - status = firing (hardcoded) Removed: - _extract_severity_from_email() method - _extract_status_from_email() method - update_mailgun_alert_metadata.py script This matches the original Mailgun provider behavior where all email alerts have the same severity/status regardless of content. --- .../mailgun_provider/mailgun_provider.py | 83 +------ scripts/update_mailgun_alert_metadata.py | 221 ------------------ 2 files changed, 3 insertions(+), 301 deletions(-) delete mode 100644 scripts/update_mailgun_alert_metadata.py diff --git a/keep/providers/mailgun_provider/mailgun_provider.py b/keep/providers/mailgun_provider/mailgun_provider.py index 2971fadee4..940e472526 100644 --- a/keep/providers/mailgun_provider/mailgun_provider.py +++ b/keep/providers/mailgun_provider/mailgun_provider.py @@ -435,83 +435,6 @@ def _extract_message_content(event: dict, email_type: str) -> str: return "No message content available" - @staticmethod - def _extract_severity_from_email(event: dict, email_type: str) -> str: - """ - Extract severity from email content and type. - - Args: - event: Email event data - email_type: Type of email (dmarc_report, spf_report, bounce, alert) - - Returns: - str: Severity level (critical, high, warning, low, info) - """ - # Get subject and body for keyword analysis - subject = (event.get("subject") or "").lower() - body = (event.get("stripped-text") or event.get("Body-plain") or "").lower() - combined_text = f"{subject} {body}" - - # Critical keywords - critical_keywords = ["critical", "emergency", "fatal", "disaster", "down", "outage", "failed"] - if any(keyword in combined_text for keyword in critical_keywords): - return "critical" - - # High/Error keywords - high_keywords = ["error", "high", "urgent", "failure", "exception", "alert"] - if any(keyword in combined_text for keyword in high_keywords): - return "high" - - # Warning keywords - warning_keywords = ["warning", "warn", "caution", "attention", "degraded"] - if any(keyword in combined_text for keyword in warning_keywords): - return "warning" - - # Success/OK keywords (low severity) - success_keywords = ["success", "successful", "completed", "ok", "healthy", "recovered"] - if any(keyword in combined_text for keyword in success_keywords): - return "low" - - # Email type based severity - if email_type == "dmarc_report": - return "low" # DMARC reports are informational - elif email_type == "spf_report": - return "warning" # SPF failures are warnings - elif email_type == "bounce": - return "warning" # Bounces are warnings - - # Default - return "info" - - @staticmethod - def _extract_status_from_email(event: dict) -> str: - """ - Extract status from email content. - - Args: - event: Email event data - - Returns: - str: Status (firing, resolved, acknowledged) - """ - # Get subject and body for keyword analysis - subject = (event.get("subject") or "").lower() - body = (event.get("stripped-text") or event.get("Body-plain") or "").lower() - combined_text = f"{subject} {body}" - - # Resolved keywords - resolved_keywords = ["resolved", "cleared", "recovered", "fixed", "closed", "ok now", "back to normal", "restoration"] - if any(keyword in combined_text for keyword in resolved_keywords): - return "resolved" - - # Acknowledged keywords - acknowledged_keywords = ["acknowledged", "ack", "investigating", "working on"] - if any(keyword in combined_text for keyword in acknowledged_keywords): - return "acknowledged" - - # Default to firing for new alerts - return "firing" - @staticmethod def _log_email_processing(event: dict, email_type: str, action: str): """ @@ -621,9 +544,9 @@ def _format_alert( except Exception: timestamp = datetime.datetime.now().isoformat() - # Extract severity and status from email content and type - severity = MailgunProvider._extract_severity_from_email(event, email_type) - status = MailgunProvider._extract_status_from_email(event) + # Default values (same as original) + severity = "info" + status = "firing" # Clean redundant fields event.pop("signature", None) diff --git a/scripts/update_mailgun_alert_metadata.py b/scripts/update_mailgun_alert_metadata.py deleted file mode 100644 index 539279dcac..0000000000 --- a/scripts/update_mailgun_alert_metadata.py +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env python3 -""" -Update severity and status for Mailgun alerts that were processed without -the intelligent extraction logic. - -This script identifies Mailgun alerts with default severity/status values -and updates them using the new intelligent extraction methods. -""" - -import sys -import os -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from keep.api.core.db import get_session_sync -from keep.api.models.db.alert import Alert -from keep.providers.mailgun_provider.mailgun_provider import MailgunProvider -from sqlalchemy import and_, or_ -from datetime import datetime, timedelta - - -def update_mailgun_alerts(tenant_id: str, dry_run: bool = True, days_back: int = 30): - """ - Update severity and status for Mailgun alerts. - - Args: - tenant_id: Tenant ID to update alerts for - dry_run: If True, only show what would be updated - days_back: How many days back to check (default: 30) - """ - session = get_session_sync() - - try: - # Calculate cutoff date - cutoff_date = datetime.utcnow() - timedelta(days=days_back) - - print(f"Searching for Mailgun alerts from the last {days_back} days...") - print(f"Tenant ID: {tenant_id}") - print(f"Cutoff date: {cutoff_date}") - print() - - # Get Mailgun alerts that likely need updating - # Focus on alerts with default severity="info" which might be wrong - alerts = session.query(Alert).filter( - and_( - Alert.tenant_id == tenant_id, - Alert.provider_type == "mailgun", - Alert.timestamp >= cutoff_date, - ) - ).all() - - print(f"Found {len(alerts)} total Mailgun alerts") - print() - - updated_count = 0 - skipped_count = 0 - error_count = 0 - - # Group by severity for reporting - severity_changes = {} - status_changes = {} - - for alert in alerts: - try: - # Get current values - event = alert.event - current_severity = event.get("severity", "info") - current_status = event.get("status", "firing") - alert_name = event.get("name", "Unknown")[:80] - - # Build pseudo-event for extraction - email_type = event.get("email_type", "alert") - - # If no email_type, try to classify from available data - if not email_type or email_type == "alert": - # Get source (email sender) - source_list = event.get("source", []) - email_from = source_list[0] if isinstance(source_list, list) and len(source_list) > 0 else "" - - pseudo_event = { - "from": email_from, - "subject": event.get("name", ""), - "stripped-text": event.get("message", ""), - "Body-plain": event.get("description", ""), - "Content-Type": event.get("Content-Type", ""), - } - email_type = MailgunProvider._classify_email_type(pseudo_event) - - # Extract severity and status using new logic - extraction_event = { - "subject": event.get("name", ""), - "stripped-text": event.get("message", ""), - "Body-plain": event.get("description", ""), - } - - new_severity = MailgunProvider._extract_severity_from_email( - extraction_event, - email_type - ) - new_status = MailgunProvider._extract_status_from_email( - extraction_event - ) - - # Check if update is needed - needs_update = ( - current_severity != new_severity or - current_status != new_status or - not event.get("email_type") - ) - - if needs_update: - print(f"Alert: {alert_name}") - - if current_severity != new_severity: - print(f" Severity: {current_severity} → {new_severity}") - severity_changes[f"{current_severity}→{new_severity}"] = \ - severity_changes.get(f"{current_severity}→{new_severity}", 0) + 1 - - if current_status != new_status: - print(f" Status: {current_status} → {new_status}") - status_changes[f"{current_status}→{new_status}"] = \ - status_changes.get(f"{current_status}→{new_status}", 0) + 1 - - if not event.get("email_type"): - print(f" Email Type: (none) → {email_type}") - - print(f" Timestamp: {alert.timestamp}") - print() - - if not dry_run: - # Update the event dict - event["severity"] = new_severity - event["status"] = new_status - if not event.get("email_type"): - event["email_type"] = email_type - - # Update in database - alert.event = event - session.add(alert) - updated_count += 1 - else: - skipped_count += 1 - - except Exception as e: - print(f"❌ Error processing alert {alert.id}: {e}") - error_count += 1 - continue - - # Commit if not dry run - if not dry_run and updated_count > 0: - session.commit() - print(f"\n✅ Successfully updated {updated_count} alerts") - elif dry_run and updated_count > 0: - print(f"\n📋 DRY RUN: Would update {updated_count} alerts") - print("Run with --apply to actually update the database") - else: - print(f"\n✅ No alerts need updating") - - # Print summary - print(f"\nSummary:") - print(f" Total alerts checked: {len(alerts)}") - print(f" Need updating: {updated_count}") - print(f" Already correct: {skipped_count}") - print(f" Errors: {error_count}") - - if severity_changes: - print(f"\nSeverity changes:") - for change, count in severity_changes.items(): - print(f" {change}: {count} alerts") - - if status_changes: - print(f"\nStatus changes:") - for change, count in status_changes.items(): - print(f" {change}: {count} alerts") - - finally: - session.close() - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser( - description="Update severity and status for Mailgun alerts" - ) - parser.add_argument( - "--tenant-id", - default="keep", - help="Tenant ID (default: keep)" - ) - parser.add_argument( - "--apply", - action="store_true", - help="Actually update the database (default is dry-run)" - ) - parser.add_argument( - "--days", - type=int, - default=30, - help="How many days back to check (default: 30)" - ) - - args = parser.parse_args() - - print("=" * 70) - print("Mailgun Alert Metadata Update Script") - print("=" * 70) - print() - - if args.apply: - print("⚠️ APPLY MODE: Will update the database") - else: - print("📋 DRY RUN MODE: Will only show what would change") - - print() - - update_mailgun_alerts( - tenant_id=args.tenant_id, - dry_run=not args.apply, - days_back=args.days - ) - From fe1915a86f64fd535a9a071a5018983d2b23df3a Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Thu, 16 Oct 2025 12:26:23 +0300 Subject: [PATCH 11/12] docs: Update Mailgun provider documentation with new config options Update auto-generated documentation to include new configuration fields: - skip_dmarc_reports: Skip DMARC reports - skip_spf_reports: Skip SPF reports - handle_emails_without_body: Handle emails without body content Generated using: python scripts/docs_render_provider_snippets.py --- docs/snippets/providers/mailgun-snippet-autogenerated.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/snippets/providers/mailgun-snippet-autogenerated.mdx b/docs/snippets/providers/mailgun-snippet-autogenerated.mdx index 521dc778dd..5db9da9019 100644 --- a/docs/snippets/providers/mailgun-snippet-autogenerated.mdx +++ b/docs/snippets/providers/mailgun-snippet-autogenerated.mdx @@ -7,6 +7,9 @@ This provider requires authentication. - **sender**: Sender email address to validate (required: False, sensitive: False) - **email_domain**: Custom email domain for receiving alerts (required: False, sensitive: False) - **extraction**: Extraction Rules (required: False, sensitive: False) +- **skip_dmarc_reports**: Skip DMARC reports (required: False, sensitive: False) +- **skip_spf_reports**: Skip SPF reports (required: False, sensitive: False) +- **handle_emails_without_body**: Handle emails without body content (required: False, sensitive: False) ## In workflows From e3a8421f78cd1c994780cacd8d9b72c278b53f10 Mon Sep 17 00:00:00 2001 From: sanyo4ever Date: Tue, 21 Oct 2025 15:50:38 +0300 Subject: [PATCH 12/12] fix: Trigger workflows when incidents are auto-resolved - Add workflow event trigger in resolve_incident_if_require() - Set end_time when incident auto-resolves - Update client via pusher on auto-resolve - Fixes issue where auto-resolved incidents didn't trigger workflows while manual resolution did --- keep/api/bl/incidents_bl.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/keep/api/bl/incidents_bl.py b/keep/api/bl/incidents_bl.py index 21eed6571f..a4a289420f 100644 --- a/keep/api/bl/incidents_bl.py +++ b/keep/api/bl/incidents_bl.py @@ -458,8 +458,21 @@ def resolve_incident_if_require( for attempt in range(max_retries): try: incident.status = IncidentStatus.RESOLVED.value + incident.end_time = datetime.now(tz=timezone.utc) self.session.add(incident) self.session.commit() + + # Trigger workflows on auto-resolved incident + self.logger.info( + "Incident auto-resolved, triggering workflows", + extra={"incident_id": incident_id, "tenant_id": self.tenant_id}, + ) + incident_dto = IncidentDto.from_db_incident(incident) + self.send_workflow_event(incident_dto, "updated") + + # Update client + self.update_client_on_incident_change(incident_id) + break except StaleDataError as ex: if "expected to update" in ex.args[0]: