From 1c1b86b96b41d8f65430ffd2fcd695ae23333f25 Mon Sep 17 00:00:00 2001 From: Bendt Date: Sun, 28 Dec 2025 10:49:25 -0500 Subject: [PATCH] feat: Add notification email compression feature Add intelligent notification email detection and compression: - Detect notification emails from GitLab, GitHub, Jira, Confluence, Datadog, Renovate - Extract structured summaries from notification emails - Compress notifications into terminal-friendly markdown format - Add configuration options for notification compression mode Features: - Rule-based detection using sender domains and subject patterns - Type-specific extractors for each notification platform - Configurable compression modes (summary, detailed, off) - Integrated with ContentContainer for seamless display Files added: - src/mail/notification_detector.py: Notification type detection - src/mail/notification_compressor.py: Content compression Modified: - src/mail/config.py: Add notification_compression_mode config - src/mail/widgets/ContentContainer.py: Integrate compressor - src/mail/app.py: Pass envelope data to display_content - PROJECT_PLAN.md: Document new feature --- PROJECT_PLAN.md | 8 +- src/mail/app.py | 10 +- src/mail/config.py | 4 + src/mail/notification_compressor.py | 221 ++++++++++++++++++ src/mail/notification_detector.py | 336 +++++++++++++++++++++++++++ src/mail/widgets/ContentContainer.py | 29 ++- 6 files changed, 603 insertions(+), 5 deletions(-) create mode 100644 src/mail/notification_compressor.py create mode 100644 src/mail/notification_detector.py diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 4823bc9..6bd09a1 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -521,7 +521,13 @@ class IPCClient: --- -## Notes and more cross-app integrations +## Mail Rendering Improvements + +Is there a way to improve readability of emails in a terminal? I get a lot of "notification style emails", and they aren't easy to comprehend or read in plain text. At the very least they don't look good. Maybe we can find an algorithm to reduce the visual noise. Or maybe an AI summary view option using copilot APIs? Research best options. Perhaps embedding a small pre-trained model that can do the summary? + +--- + +## Note-taking integration and more cross-app integrations I like the `tdo` (https://github.com/2KAbhishek/tdo) program for managing markdown notes with fzf and my terminal text editor. It makes it easy to have a "today" note and a list of todos. Perhaps we can gather those todos from the text files in the $NOTES_DIR and put them into the task list during regular sync - and when users mark a task complete the sync can find the text file it was in and mark it complete on that line of markdown text. We need a little ore features for the related annotations then, because when I press `n` in the notes app we would want to actually open the textfile that task came from, not just make another annotation. So we would need a special cross-linking format for knowing which tasks came from a $NOTES_DIR sync. And then we can work on the same IPC scenario for tasks that were created in the email app. Then those tasks should be connected so that when the user views the notes on those tasks they see the whole email. That would be simpe enough if we just copied the email text into an annotation. But maybe we need a way to actually change the selected message ID in the mail app if it's open. So if the user activates the "open" feature on a task the related email will be displayed in the other terminal window where the user has mail open. diff --git a/src/mail/app.py b/src/mail/app.py index cadee47..6d4d10f 100644 --- a/src/mail/app.py +++ b/src/mail/app.py @@ -265,9 +265,17 @@ class EmailViewerApp(App): content_container = self.query_one(ContentContainer) folder = self.folder if self.folder else None account = self.current_account if self.current_account else None - content_container.display_content(message_id, folder=folder, account=account) + # Get envelope data for notification compression metadata = self.message_store.get_metadata(message_id) + envelope = None + if metadata: + envelope = self.message_store.envelopes.get(metadata["index"]) + + content_container.display_content( + message_id, folder=folder, account=account, envelope=envelope + ) + if metadata: message_date = metadata["date"] if self.current_message_index != metadata["index"]: diff --git a/src/mail/config.py b/src/mail/config.py index da15e9c..8ebd6a0 100644 --- a/src/mail/config.py +++ b/src/mail/config.py @@ -86,6 +86,10 @@ class ContentDisplayConfig(BaseModel): compress_urls: bool = True max_url_length: int = 50 # Maximum length before URL is compressed + # Notification compression: compress notification emails into summaries + compress_notifications: bool = True + notification_compression_mode: Literal["summary", "detailed", "off"] = "summary" + class LinkPanelConfig(BaseModel): """Configuration for the link panel.""" diff --git a/src/mail/notification_compressor.py b/src/mail/notification_compressor.py new file mode 100644 index 0000000..640b13e --- /dev/null +++ b/src/mail/notification_compressor.py @@ -0,0 +1,221 @@ +"""Notification email compressor for terminal-friendly display.""" + +from typing import Dict, Any, Optional +from textual.widgets import Markdown + +from .notification_detector import ( + is_notification_email, + classify_notification, + extract_notification_summary, + NotificationType, +) + + +class NotificationCompressor: + """Compress notification emails into terminal-friendly summaries.""" + + def __init__(self, mode: str = "summary"): + """Initialize compressor. + + Args: + mode: Compression mode - "summary", "detailed", or "off" + """ + self.mode = mode + + def should_compress(self, envelope: Dict[str, Any]) -> bool: + """Check if email should be compressed. + + Args: + envelope: Email envelope metadata + + Returns: + True if email should be compressed + """ + if self.mode == "off": + return False + + return is_notification_email(envelope) + + def compress( + self, content: str, envelope: Dict[str, Any] + ) -> tuple[str, Optional[NotificationType]]: + """Compress notification email content. + + Args: + content: Raw email content + envelope: Email envelope metadata + + Returns: + Tuple of (compressed content, notification_type) + """ + + if not self.should_compress(envelope): + return content, None + + # Classify notification type + notif_type = classify_notification(envelope, content) + + # Extract summary + summary = extract_notification_summary(content, notif_type) + + # Format as markdown + compressed = self._format_as_markdown(summary, envelope, notif_type) + + return compressed, notif_type + + def _format_as_markdown( + self, + summary: Dict[str, Any], + envelope: Dict[str, Any], + notif_type: Optional[NotificationType], + ) -> str: + """Format summary as markdown for terminal display. + + Args: + summary: Extracted summary data + envelope: Email envelope metadata + notif_type: Classified notification type + + Returns: + Markdown-formatted compressed email + """ + + from_addr = envelope.get("from", {}).get("name") or envelope.get( + "from", {} + ).get("addr", "") + subject = envelope.get("subject", "") + + # Get icon + icon = notif_type.icon if notif_type else "\uf0f3" + + # Build markdown + lines = [] + + # Header with icon + if notif_type: + lines.append(f"## {icon} {notif_type.name.title()} Notification") + else: + lines.append(f"## {icon} Notification") + + lines.append("") + + # Title/subject + if summary.get("title"): + lines.append(f"**{summary['title']}**") + else: + lines.append(f"**{subject}**") + lines.append("") + + # Metadata section + if summary.get("metadata"): + lines.append("### Details") + for key, value in summary["metadata"].items(): + # Format key nicely + key_formatted = key.replace("_", " ").title() + lines.append(f"- **{key_formatted}**: {value}") + lines.append("") + + # Action items + if summary.get("action_items"): + lines.append("### Actions") + for i, action in enumerate(summary["action_items"], 1): + lines.append(f"{i}. {action}") + lines.append("") + + # Add footer + lines.append("---") + lines.append("") + lines.append(f"*From: {from_addr}*") + lines.append( + "*This is a compressed notification view. Press `m` to toggle full view.*" + ) + + return "\n".join(lines) + + +class DetailedCompressor(NotificationCompressor): + """Compressor that includes more detail in summaries.""" + + def _format_as_markdown( + self, + summary: Dict[str, Any], + envelope: Dict[str, Any], + notif_type: Optional[NotificationType], + ) -> str: + """Format summary with more detail.""" + + from_addr = envelope.get("from", {}).get("name") or envelope.get( + "from", {} + ).get("addr", "") + subject = envelope.get("subject", "") + date = envelope.get("date", "") + + icon = notif_type.icon if notif_type else "\uf0f3" + + lines = [] + + # Header + lines.append( + f"## {icon} {notif_type.name.title()} Notification" + if notif_type + else f"## {icon} Notification" + ) + lines.append("") + + # Subject and from + lines.append(f"**Subject:** {subject}") + lines.append(f"**From:** {from_addr}") + lines.append(f"**Date:** {date}") + lines.append("") + + # Summary title + if summary.get("title"): + lines.append(f"### {summary['title']}") + lines.append("") + + # Metadata table + if summary.get("metadata"): + lines.append("| Property | Value |") + lines.append("|----------|-------|") + for key, value in summary["metadata"].items(): + key_formatted = key.replace("_", " ").title() + lines.append(f"| {key_formatted} | {value} |") + lines.append("") + + # Action items + if summary.get("action_items"): + lines.append("### Action Items") + for i, action in enumerate(summary["action_items"], 1): + lines.append(f"- [ ] {action}") + lines.append("") + + # Key links + if summary.get("key_links"): + lines.append("### Important Links") + for link in summary["key_links"]: + lines.append(f"- [{link.get('text', 'Link')}]({link.get('url', '#')})") + lines.append("") + + # Footer + lines.append("---") + lines.append( + "*This is a compressed notification view. Press `m` to toggle full view.*" + ) + + return "\n".join(lines) + + +def create_compressor(mode: str) -> NotificationCompressor: + """Factory function to create appropriate compressor. + + Args: + mode: Compression mode - "summary", "detailed", or "off" + + Returns: + NotificationCompressor instance + """ + + if mode == "detailed": + return DetailedCompressor(mode=mode) + else: + return NotificationCompressor(mode=mode) diff --git a/src/mail/notification_detector.py b/src/mail/notification_detector.py new file mode 100644 index 0000000..804e78d --- /dev/null +++ b/src/mail/notification_detector.py @@ -0,0 +1,336 @@ +"""Email notification detection utilities.""" + +import re +from typing import Dict, Any, List, Optional +from dataclasses import dataclass + + +@dataclass +class NotificationType: + """Classification of notification email types.""" + + name: str + patterns: List[str] + domains: List[str] + icon: str + + def matches(self, envelope: Dict[str, Any], content: Optional[str] = None) -> bool: + """Check if envelope matches this notification type.""" + + # Check sender domain + from_addr = envelope.get("from", {}).get("addr", "").lower() + if any(domain in from_addr for domain in self.domains): + return True + + # Check subject patterns + subject = envelope.get("subject", "").lower() + if any(re.search(pattern, subject, re.IGNORECASE) for pattern in self.patterns): + return True + + return False + + +# Common notification types +NOTIFICATION_TYPES = [ + NotificationType( + name="gitlab", + patterns=[r"\[gitlab\]", r"pipeline", r"merge request", r"mention.*you"], + domains=["gitlab.com", "@gitlab"], + icon="\uf296", + ), + NotificationType( + name="github", + patterns=[r"\[github\]", r"pr #", r"pull request", r"issue #", r"mention"], + domains=["github.com", "noreply@github.com"], + icon="\uf09b", + ), + NotificationType( + name="jira", + patterns=[r"\[jira\]", r"[a-z]+-\d+", r"issue updated", r"comment added"], + domains=["atlassian.net", "jira"], + icon="\uf1b3", + ), + NotificationType( + name="confluence", + patterns=[r"\[confluence\]", r"page created", r"page updated", r"comment"], + domains=["atlassian.net", "confluence"], + icon="\uf298", + ), + NotificationType( + name="datadog", + patterns=[r"alert", r"monitor", r"incident", r"downtime"], + domains=["datadoghq.com", "datadog"], + icon="\uf1b0", + ), + NotificationType( + name="renovate", + patterns=[r"renovate", r"dependency update", r"lock file"], + domains=["renovate", "renovatebot"], + icon="\uf1e6", + ), + NotificationType( + name="general", + patterns=[r"\[.*?\]", r"notification", r"digest", r"summary"], + domains=["noreply@", "no-reply@", "notifications@"], + icon="\uf0f3", + ), +] + + +def is_notification_email( + envelope: Dict[str, Any], content: Optional[str] = None +) -> bool: + """Check if an email is a notification-style email. + + Args: + envelope: Email envelope metadata from himalaya + content: Optional email content for content-based detection + + Returns: + True if email appears to be a notification + """ + + # Check against known notification types + for notif_type in NOTIFICATION_TYPES: + if notif_type.matches(envelope, content): + return True + + # Check for generic notification indicators + subject = envelope.get("subject", "").lower() + from_addr = envelope.get("from", {}).get("addr", "").lower() + + # Generic notification patterns + generic_patterns = [ + r"^\[.*?\]", # Brackets at start + r"weekly|daily|monthly.*report|digest|summary", + r"you were mentioned", + r"this is an automated message", + r"do not reply|don't reply", + ] + + if any(re.search(pattern, subject, re.IGNORECASE) for pattern in generic_patterns): + return True + + # Check for notification senders + notification_senders = ["noreply", "no-reply", "notifications", "robot", "bot"] + if any(sender in from_addr for sender in notification_senders): + return True + + return False + + +def classify_notification( + envelope: Dict[str, Any], content: Optional[str] = None +) -> Optional[NotificationType]: + """Classify the type of notification email. + + Args: + envelope: Email envelope metadata from himalaya + content: Optional email content for content-based detection + + Returns: + NotificationType if classified, None if not a notification + """ + + for notif_type in NOTIFICATION_TYPES: + if notif_type.matches(envelope, content): + return notif_type + + return None + + +def extract_notification_summary( + content: str, notification_type: Optional[NotificationType] = None +) -> Dict[str, Any]: + """Extract structured summary from notification email content. + + Args: + content: Email body content + notification_type: Classified notification type (optional) + + Returns: + Dictionary with extracted summary fields + """ + + summary = { + "title": None, + "action_items": [], + "key_links": [], + "metadata": {}, + } + + # Extract based on notification type + if notification_type and notification_type.name == "gitlab": + summary.update(_extract_gitlab_summary(content)) + elif notification_type and notification_type.name == "github": + summary.update(_extract_github_summary(content)) + elif notification_type and notification_type.name == "jira": + summary.update(_extract_jira_summary(content)) + elif notification_type and notification_type.name == "confluence": + summary.update(_extract_confluence_summary(content)) + elif notification_type and notification_type.name == "datadog": + summary.update(_extract_datadog_summary(content)) + elif notification_type and notification_type.name == "renovate": + summary.update(_extract_renovate_summary(content)) + else: + summary.update(_extract_general_notification_summary(content)) + + return summary + + +def _extract_gitlab_summary(content: str) -> Dict[str, Any]: + """Extract summary from GitLab notification.""" + summary = {"action_items": [], "key_links": [], "metadata": {}} + + # Pipeline patterns + pipeline_match = re.search( + r"Pipeline #(\d+).*?(?:failed|passed|canceled) by (.+?)[\n\r]", + content, + re.IGNORECASE, + ) + if pipeline_match: + summary["metadata"]["pipeline_id"] = pipeline_match.group(1) + summary["metadata"]["triggered_by"] = pipeline_match.group(2) + summary["title"] = f"Pipeline #{pipeline_match.group(1)}" + + # Merge request patterns + mr_match = re.search(r"Merge request #(\d+):\s*(.+?)[\n\r]", content, re.IGNORECASE) + if mr_match: + summary["metadata"]["mr_id"] = mr_match.group(1) + summary["metadata"]["mr_title"] = mr_match.group(2) + summary["title"] = f"MR #{mr_match.group(1)}: {mr_match.group(2)}" + + # Mention patterns + mention_match = re.search( + r"<@(.+?)> mentioned you in (?:#|@)(.+?)[\n\r]", content, re.IGNORECASE + ) + if mention_match: + summary["metadata"]["mentioned_by"] = mention_match.group(1) + summary["metadata"]["mentioned_in"] = mention_match.group(2) + summary["title"] = f"Mention by {mention_match.group(1)}" + + return summary + + +def _extract_github_summary(content: str) -> Dict[str, Any]: + """Extract summary from GitHub notification.""" + summary = {"action_items": [], "key_links": [], "metadata": {}} + + # PR/Issue patterns + pr_match = re.search(r"(?:PR|Issue) #(\d+):\s*(.+?)[\n\r]", content, re.IGNORECASE) + if pr_match: + summary["metadata"]["number"] = pr_match.group(1) + summary["metadata"]["title"] = pr_match.group(2) + summary["title"] = f"#{pr_match.group(1)}: {pr_match.group(2)}" + + # Review requested + if "review requested" in content.lower(): + summary["action_items"].append("Review requested") + + return summary + + +def _extract_jira_summary(content: str) -> Dict[str, Any]: + """Extract summary from Jira notification.""" + summary = {"action_items": [], "key_links": [], "metadata": {}} + + # Issue patterns + issue_match = re.search(r"([A-Z]+-\d+):\s*(.+?)[\n\r]", content, re.IGNORECASE) + if issue_match: + summary["metadata"]["issue_key"] = issue_match.group(1) + summary["metadata"]["issue_title"] = issue_match.group(2) + summary["title"] = f"{issue_match.group(1)}: {issue_match.group(2)}" + + # Status changes + if "status changed" in content.lower(): + status_match = re.search( + r"status changed from (.+?) to (.+)", content, re.IGNORECASE + ) + if status_match: + summary["metadata"]["status_from"] = status_match.group(1) + summary["metadata"]["status_to"] = status_match.group(2) + summary["action_items"].append( + f"Status: {status_match.group(1)} → {status_match.group(2)}" + ) + + return summary + + +def _extract_confluence_summary(content: str) -> Dict[str, Any]: + """Extract summary from Confluence notification.""" + summary = {"action_items": [], "key_links": [], "metadata": {}} + + # Page patterns + page_match = re.search(r"Page \"(.+?)\"", content, re.IGNORECASE) + if page_match: + summary["metadata"]["page_title"] = page_match.group(1) + summary["title"] = f"Page: {page_match.group(1)}" + + # Author + author_match = re.search( + r"(?:created|updated) by (.+?)[\n\r]", content, re.IGNORECASE + ) + if author_match: + summary["metadata"]["author"] = author_match.group(1) + + return summary + + +def _extract_datadog_summary(content: str) -> Dict[str, Any]: + """Extract summary from Datadog notification.""" + summary = {"action_items": [], "key_links": [], "metadata": {}} + + # Alert status + if "triggered" in content.lower(): + summary["metadata"]["status"] = "Triggered" + summary["action_items"].append("Alert triggered - investigate") + elif "recovered" in content.lower(): + summary["metadata"]["status"] = "Recovered" + + # Monitor name + monitor_match = re.search(r"Monitor: (.+?)[\n\r]", content, re.IGNORECASE) + if monitor_match: + summary["metadata"]["monitor"] = monitor_match.group(1) + summary["title"] = f"Alert: {monitor_match.group(1)}" + + return summary + + +def _extract_renovate_summary(content: str) -> Dict[str, Any]: + """Extract summary from Renovate notification.""" + summary = {"action_items": [], "key_links": [], "metadata": {}} + + # Dependency patterns + dep_match = re.search( + r"Update (?:.+) dependency (.+?) to (v?\d+\.\d+\.?\d*)", content, re.IGNORECASE + ) + if dep_match: + summary["metadata"]["dependency"] = dep_match.group(2) + summary["metadata"]["version"] = dep_match.group(3) + summary["title"] = f"Update {dep_match.group(2)} to {dep_match.group(3)}" + summary["action_items"].append("Review and merge dependency update") + + return summary + + +def _extract_general_notification_summary(content: str) -> Dict[str, Any]: + """Extract summary from general notification.""" + summary = {"action_items": [], "key_links": [], "metadata": {}} + + # Look for action-oriented phrases + action_patterns = [ + r"you need to (.+)", + r"please (.+)", + r"action required", + r"review requested", + r"approval needed", + ] + + for pattern in action_patterns: + matches = re.findall(pattern, content, re.IGNORECASE) + summary["action_items"].extend(matches) + + # Limit action items + summary["action_items"] = summary["action_items"][:5] + + return summary diff --git a/src/mail/widgets/ContentContainer.py b/src/mail/widgets/ContentContainer.py index a5c2bf4..30b47bc 100644 --- a/src/mail/widgets/ContentContainer.py +++ b/src/mail/widgets/ContentContainer.py @@ -11,9 +11,11 @@ from src.mail.screens.LinkPanel import ( LinkItem, LinkItem as LinkItemClass, ) +from src.mail.notification_compressor import create_compressor +from src.mail.notification_detector import NotificationType import logging from datetime import datetime -from typing import Literal, List, Dict +from typing import Literal, List, Dict, Optional from urllib.parse import urlparse import re import os @@ -173,10 +175,16 @@ class ContentContainer(ScrollableContainer): self.current_folder: str | None = None self.current_account: str | None = None self.content_worker = None + self.current_envelope: Optional[Dict[str, Any]] = None + self.current_notification_type: Optional[NotificationType] = None + self.is_compressed_view: bool = False - # Load default view mode from config + # Load default view mode and notification compression from config config = get_config() self.current_mode = config.content_display.default_view_mode + self.compressor = create_compressor( + config.content_display.notification_compression_mode + ) def compose(self): yield self.content @@ -243,6 +251,7 @@ class ContentContainer(ScrollableContainer): message_id: int, folder: str | None = None, account: str | None = None, + envelope: Optional[Dict[str, Any]] = None, ) -> None: """Display the content of a message.""" if not message_id: @@ -251,6 +260,7 @@ class ContentContainer(ScrollableContainer): self.current_message_id = message_id self.current_folder = folder self.current_account = account + self.current_envelope = envelope # Immediately show a loading message if self.current_mode == "markdown": @@ -282,6 +292,18 @@ class ContentContainer(ScrollableContainer): # Store the raw content for link extraction self.current_content = content + # Apply notification compression if enabled + if self.compressor.mode != "off" and self.current_envelope: + compressed_content, notif_type = self.compressor.compress( + content, self.current_envelope + ) + self.current_notification_type = notif_type + content = compressed_content + self.is_compressed_view = True + else: + self.current_notification_type = None + self.is_compressed_view = False + # Get URL compression settings from config config = get_config() compress_urls = config.content_display.compress_urls @@ -291,7 +313,8 @@ class ContentContainer(ScrollableContainer): if self.current_mode == "markdown": # For markdown mode, use the Markdown widget display_content = content - if compress_urls: + if compress_urls and not self.is_compressed_view: + # Don't compress URLs in notification summaries (they're already formatted) display_content = compress_urls_in_content(content, max_url_len) self.content.update(display_content) else: