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
This commit is contained in:
@@ -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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -265,9 +265,17 @@ class EmailViewerApp(App):
|
|||||||
content_container = self.query_one(ContentContainer)
|
content_container = self.query_one(ContentContainer)
|
||||||
folder = self.folder if self.folder else None
|
folder = self.folder if self.folder else None
|
||||||
account = self.current_account if self.current_account 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)
|
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:
|
if metadata:
|
||||||
message_date = metadata["date"]
|
message_date = metadata["date"]
|
||||||
if self.current_message_index != metadata["index"]:
|
if self.current_message_index != metadata["index"]:
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ class ContentDisplayConfig(BaseModel):
|
|||||||
compress_urls: bool = True
|
compress_urls: bool = True
|
||||||
max_url_length: int = 50 # Maximum length before URL is compressed
|
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):
|
class LinkPanelConfig(BaseModel):
|
||||||
"""Configuration for the link panel."""
|
"""Configuration for the link panel."""
|
||||||
|
|||||||
221
src/mail/notification_compressor.py
Normal file
221
src/mail/notification_compressor.py
Normal file
@@ -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)
|
||||||
336
src/mail/notification_detector.py
Normal file
336
src/mail/notification_detector.py
Normal file
@@ -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
|
||||||
@@ -11,9 +11,11 @@ from src.mail.screens.LinkPanel import (
|
|||||||
LinkItem,
|
LinkItem,
|
||||||
LinkItem as LinkItemClass,
|
LinkItem as LinkItemClass,
|
||||||
)
|
)
|
||||||
|
from src.mail.notification_compressor import create_compressor
|
||||||
|
from src.mail.notification_detector import NotificationType
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, List, Dict
|
from typing import Literal, List, Dict, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
@@ -173,10 +175,16 @@ class ContentContainer(ScrollableContainer):
|
|||||||
self.current_folder: str | None = None
|
self.current_folder: str | None = None
|
||||||
self.current_account: str | None = None
|
self.current_account: str | None = None
|
||||||
self.content_worker = 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()
|
config = get_config()
|
||||||
self.current_mode = config.content_display.default_view_mode
|
self.current_mode = config.content_display.default_view_mode
|
||||||
|
self.compressor = create_compressor(
|
||||||
|
config.content_display.notification_compression_mode
|
||||||
|
)
|
||||||
|
|
||||||
def compose(self):
|
def compose(self):
|
||||||
yield self.content
|
yield self.content
|
||||||
@@ -243,6 +251,7 @@ class ContentContainer(ScrollableContainer):
|
|||||||
message_id: int,
|
message_id: int,
|
||||||
folder: str | None = None,
|
folder: str | None = None,
|
||||||
account: str | None = None,
|
account: str | None = None,
|
||||||
|
envelope: Optional[Dict[str, Any]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Display the content of a message."""
|
"""Display the content of a message."""
|
||||||
if not message_id:
|
if not message_id:
|
||||||
@@ -251,6 +260,7 @@ class ContentContainer(ScrollableContainer):
|
|||||||
self.current_message_id = message_id
|
self.current_message_id = message_id
|
||||||
self.current_folder = folder
|
self.current_folder = folder
|
||||||
self.current_account = account
|
self.current_account = account
|
||||||
|
self.current_envelope = envelope
|
||||||
|
|
||||||
# Immediately show a loading message
|
# Immediately show a loading message
|
||||||
if self.current_mode == "markdown":
|
if self.current_mode == "markdown":
|
||||||
@@ -282,6 +292,18 @@ class ContentContainer(ScrollableContainer):
|
|||||||
# Store the raw content for link extraction
|
# Store the raw content for link extraction
|
||||||
self.current_content = content
|
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
|
# Get URL compression settings from config
|
||||||
config = get_config()
|
config = get_config()
|
||||||
compress_urls = config.content_display.compress_urls
|
compress_urls = config.content_display.compress_urls
|
||||||
@@ -291,7 +313,8 @@ class ContentContainer(ScrollableContainer):
|
|||||||
if self.current_mode == "markdown":
|
if self.current_mode == "markdown":
|
||||||
# For markdown mode, use the Markdown widget
|
# For markdown mode, use the Markdown widget
|
||||||
display_content = content
|
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)
|
display_content = compress_urls_in_content(content, max_url_len)
|
||||||
self.content.update(display_content)
|
self.content.update(display_content)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user