feat: Add invite compressor and compressed header display
- Add InviteCompressor for terminal-friendly calendar invite summaries - Add test fixtures for large group invite and cancellation emails - Compress To/CC headers to single line with '... (+N more)' truncation - Add 'h' keybinding to toggle between compressed and full headers - EnvelopeHeader now shows first 2 recipients by default
This commit is contained in:
@@ -914,6 +914,17 @@ class EmailViewerApp(App):
|
||||
"""Scroll the main content up by a page."""
|
||||
self.query_one("#main_content").scroll_page_up()
|
||||
|
||||
def action_toggle_header(self) -> None:
|
||||
"""Toggle between compressed and full envelope headers."""
|
||||
content_container = self.query_one("#main_content", ContentContainer)
|
||||
if hasattr(content_container, "header") and content_container.header:
|
||||
content_container.header.toggle_full_headers()
|
||||
# Provide visual feedback
|
||||
if content_container.header.show_full_headers:
|
||||
self.notify("Showing full headers", timeout=1)
|
||||
else:
|
||||
self.notify("Showing compressed headers", timeout=1)
|
||||
|
||||
def action_toggle_main_content(self) -> None:
|
||||
"""Toggle the visibility of the main content pane."""
|
||||
self.main_content_visible = not self.main_content_visible
|
||||
|
||||
208
src/mail/invite_compressor.py
Normal file
208
src/mail/invite_compressor.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Calendar invite compressor for terminal-friendly display."""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from .utils.calendar_parser import (
|
||||
ParsedCalendarEvent,
|
||||
parse_calendar_from_raw_message,
|
||||
format_event_time,
|
||||
is_cancelled_event,
|
||||
is_event_request,
|
||||
)
|
||||
from .notification_detector import is_calendar_email
|
||||
|
||||
|
||||
class InviteCompressor:
|
||||
"""Compress calendar invite emails into terminal-friendly summaries."""
|
||||
|
||||
# Nerdfont icons
|
||||
ICON_CALENDAR = "\uf073" # calendar icon
|
||||
ICON_CANCELLED = "\uf057" # times-circle
|
||||
ICON_INVITE = "\uf0e0" # envelope
|
||||
ICON_REPLY = "\uf3e5" # reply
|
||||
ICON_LOCATION = "\uf3c5" # map-marker-alt
|
||||
ICON_CLOCK = "\uf017" # clock
|
||||
ICON_USER = "\uf007" # user
|
||||
ICON_USERS = "\uf0c0" # users
|
||||
|
||||
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 as calendar invite.
|
||||
|
||||
Args:
|
||||
envelope: Email envelope metadata
|
||||
|
||||
Returns:
|
||||
True if email is a calendar invite that should be compressed
|
||||
"""
|
||||
if self.mode == "off":
|
||||
return False
|
||||
|
||||
return is_calendar_email(envelope)
|
||||
|
||||
def compress(
|
||||
self, raw_message: str, envelope: dict[str, Any]
|
||||
) -> tuple[str, Optional[ParsedCalendarEvent]]:
|
||||
"""Compress calendar invite email content.
|
||||
|
||||
Args:
|
||||
raw_message: Raw email MIME content
|
||||
envelope: Email envelope metadata
|
||||
|
||||
Returns:
|
||||
Tuple of (compressed content, parsed event or None)
|
||||
"""
|
||||
if not self.should_compress(envelope):
|
||||
return "", None
|
||||
|
||||
# Parse the ICS content from raw message
|
||||
event = parse_calendar_from_raw_message(raw_message)
|
||||
|
||||
if not event:
|
||||
return "", None
|
||||
|
||||
# Format as markdown
|
||||
compressed = self._format_as_markdown(event, envelope)
|
||||
|
||||
return compressed, event
|
||||
|
||||
def _format_as_markdown(
|
||||
self,
|
||||
event: ParsedCalendarEvent,
|
||||
envelope: dict[str, Any],
|
||||
) -> str:
|
||||
"""Format event as markdown for terminal display.
|
||||
|
||||
Args:
|
||||
event: Parsed calendar event
|
||||
envelope: Email envelope metadata
|
||||
|
||||
Returns:
|
||||
Markdown-formatted compressed invite
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Determine event type and icon
|
||||
if is_cancelled_event(event):
|
||||
icon = self.ICON_CANCELLED
|
||||
type_label = "CANCELLED"
|
||||
type_style = "~~" # strikethrough
|
||||
elif is_event_request(event):
|
||||
icon = self.ICON_INVITE
|
||||
type_label = "MEETING INVITE"
|
||||
type_style = "**"
|
||||
else:
|
||||
icon = self.ICON_CALENDAR
|
||||
type_label = event.method or "CALENDAR"
|
||||
type_style = ""
|
||||
|
||||
# Header
|
||||
lines.append(f"## {icon} {type_label}")
|
||||
lines.append("")
|
||||
|
||||
# Event title
|
||||
title = event.summary or envelope.get("subject", "Untitled Event")
|
||||
if is_cancelled_event(event):
|
||||
# Remove "Canceled: " prefix if present
|
||||
if title.lower().startswith("canceled:"):
|
||||
title = title[9:].strip()
|
||||
elif title.lower().startswith("cancelled:"):
|
||||
title = title[10:].strip()
|
||||
lines.append(f"~~{title}~~")
|
||||
else:
|
||||
lines.append(f"**{title}**")
|
||||
lines.append("")
|
||||
|
||||
# Time
|
||||
time_str = format_event_time(event)
|
||||
lines.append(f"{self.ICON_CLOCK} {time_str}")
|
||||
lines.append("")
|
||||
|
||||
# Location
|
||||
if event.location:
|
||||
lines.append(f"{self.ICON_LOCATION} {event.location}")
|
||||
lines.append("")
|
||||
|
||||
# Organizer
|
||||
if event.organizer_name or event.organizer_email:
|
||||
organizer = event.organizer_name or event.organizer_email
|
||||
lines.append(f"{self.ICON_USER} **Organizer:** {organizer}")
|
||||
lines.append("")
|
||||
|
||||
# Attendees (compressed)
|
||||
if event.attendees:
|
||||
attendee_summary = self._compress_attendees(event.attendees)
|
||||
lines.append(f"{self.ICON_USERS} **Attendees:** {attendee_summary}")
|
||||
lines.append("")
|
||||
|
||||
# Actions hint
|
||||
if is_event_request(event):
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("*Press `A` to Accept, `T` for Tentative, `D` to Decline*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _compress_attendees(self, attendees: list[str], max_shown: int = 3) -> str:
|
||||
"""Compress attendee list to a short summary.
|
||||
|
||||
Args:
|
||||
attendees: List of attendee strings (name <email> or just email)
|
||||
max_shown: Maximum number of attendees to show before truncating
|
||||
|
||||
Returns:
|
||||
Compressed attendee summary like "Alice, Bob, Carol... (+12 more)"
|
||||
"""
|
||||
if not attendees:
|
||||
return "None"
|
||||
|
||||
# Extract just names from attendees
|
||||
names = []
|
||||
for att in attendees:
|
||||
# Handle "Name <email>" format
|
||||
if "<" in att:
|
||||
name = att.split("<")[0].strip()
|
||||
if name:
|
||||
# Get just first name for brevity
|
||||
first_name = (
|
||||
name.split(",")[0].strip() if "," in name else name.split()[0]
|
||||
)
|
||||
names.append(first_name)
|
||||
else:
|
||||
names.append(att.split("<")[1].rstrip(">").split("@")[0])
|
||||
else:
|
||||
# Just email, use local part
|
||||
names.append(att.split("@")[0])
|
||||
|
||||
total = len(names)
|
||||
|
||||
if total <= max_shown:
|
||||
return ", ".join(names)
|
||||
else:
|
||||
shown = ", ".join(names[:max_shown])
|
||||
remaining = total - max_shown
|
||||
return f"{shown}... (+{remaining} more)"
|
||||
|
||||
|
||||
def compress_invite(
|
||||
raw_message: str, envelope: dict[str, Any], mode: str = "summary"
|
||||
) -> tuple[str, Optional[ParsedCalendarEvent]]:
|
||||
"""Convenience function to compress a calendar invite.
|
||||
|
||||
Args:
|
||||
raw_message: Raw email MIME content
|
||||
envelope: Email envelope metadata
|
||||
mode: Compression mode
|
||||
|
||||
Returns:
|
||||
Tuple of (compressed content, parsed event or None)
|
||||
"""
|
||||
compressor = InviteCompressor(mode=mode)
|
||||
return compressor.compress(raw_message, envelope)
|
||||
@@ -113,6 +113,13 @@ def compress_urls_in_content(content: str, max_url_len: int = 50) -> str:
|
||||
|
||||
|
||||
class EnvelopeHeader(Vertical):
|
||||
"""Email envelope header with compressible To/CC fields."""
|
||||
|
||||
# Maximum recipients to show before truncating
|
||||
MAX_RECIPIENTS_SHOWN = 2
|
||||
# Show full headers when toggled
|
||||
show_full_headers: bool = False
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.subject_label = Label("")
|
||||
@@ -120,6 +127,10 @@ class EnvelopeHeader(Vertical):
|
||||
self.to_label = Label("")
|
||||
self.date_label = Label("")
|
||||
self.cc_label = Label("")
|
||||
# Store full values for toggle
|
||||
self._full_to = ""
|
||||
self._full_cc = ""
|
||||
self._full_from = ""
|
||||
|
||||
def on_mount(self):
|
||||
self.styles.height = "auto"
|
||||
@@ -131,11 +142,108 @@ class EnvelopeHeader(Vertical):
|
||||
# Add bottom margin to subject for visual separation from metadata
|
||||
self.subject_label.styles.margin = (0, 0, 1, 0)
|
||||
|
||||
def _compress_recipients(self, recipients_str: str, max_shown: int = 2) -> str:
|
||||
"""Compress a list of recipients to a single line with truncation.
|
||||
|
||||
Args:
|
||||
recipients_str: Comma-separated list of recipients
|
||||
max_shown: Maximum number of recipients to show
|
||||
|
||||
Returns:
|
||||
Compressed string like "Alice, Bob... (+15 more)"
|
||||
"""
|
||||
if not recipients_str:
|
||||
return ""
|
||||
|
||||
# Split by comma, handling "Name <email>" format
|
||||
# Use regex to split on ", " only when not inside < >
|
||||
parts = []
|
||||
current = ""
|
||||
in_angle = False
|
||||
for char in recipients_str:
|
||||
if char == "<":
|
||||
in_angle = True
|
||||
elif char == ">":
|
||||
in_angle = False
|
||||
elif char == "," and not in_angle:
|
||||
if current.strip():
|
||||
parts.append(current.strip())
|
||||
current = ""
|
||||
continue
|
||||
current += char
|
||||
if current.strip():
|
||||
parts.append(current.strip())
|
||||
|
||||
total = len(parts)
|
||||
|
||||
if total <= max_shown:
|
||||
return recipients_str
|
||||
|
||||
# Extract short names from first few recipients
|
||||
short_names = []
|
||||
for part in parts[:max_shown]:
|
||||
# Handle "Last, First <email>" or just "email@example.com"
|
||||
if "<" in part:
|
||||
name = part.split("<")[0].strip()
|
||||
if name:
|
||||
# Get first name for brevity (handle "Last, First" format)
|
||||
if "," in name:
|
||||
# "Last, First" -> "First"
|
||||
name_parts = name.split(",")
|
||||
if len(name_parts) >= 2:
|
||||
name = name_parts[1].strip().split()[0]
|
||||
else:
|
||||
name = name_parts[0].strip()
|
||||
else:
|
||||
# "First Last" -> "First"
|
||||
name = name.split()[0]
|
||||
short_names.append(name)
|
||||
else:
|
||||
# No name, use email local part
|
||||
email = part.split("<")[1].rstrip(">").split("@")[0]
|
||||
short_names.append(email)
|
||||
else:
|
||||
# Just email address
|
||||
short_names.append(part.split("@")[0])
|
||||
|
||||
remaining = total - max_shown
|
||||
return f"{', '.join(short_names)}... (+{remaining} more)"
|
||||
|
||||
def toggle_full_headers(self) -> None:
|
||||
"""Toggle between compressed and full header view."""
|
||||
self.show_full_headers = not self.show_full_headers
|
||||
self._refresh_display()
|
||||
|
||||
def _refresh_display(self) -> None:
|
||||
"""Refresh the display based on current mode."""
|
||||
if self.show_full_headers:
|
||||
self.from_label.update(f"[b]From:[/b] {self._full_from}")
|
||||
self.to_label.update(f"[b]To:[/b] {self._full_to}")
|
||||
if self._full_cc:
|
||||
self.cc_label.update(f"[b]CC:[/b] {self._full_cc}")
|
||||
self.cc_label.styles.display = "block"
|
||||
else:
|
||||
# Compressed view
|
||||
self.from_label.update(
|
||||
f"[b]From:[/b] {self._compress_recipients(self._full_from, max_shown=1)}"
|
||||
)
|
||||
self.to_label.update(
|
||||
f"[b]To:[/b] {self._compress_recipients(self._full_to)}"
|
||||
)
|
||||
if self._full_cc:
|
||||
self.cc_label.update(
|
||||
f"[b]CC:[/b] {self._compress_recipients(self._full_cc)}"
|
||||
)
|
||||
self.cc_label.styles.display = "block"
|
||||
|
||||
def update(self, subject, from_, to, date, cc=None):
|
||||
# Store full values
|
||||
self._full_from = from_ or ""
|
||||
self._full_to = to or ""
|
||||
self._full_cc = cc or ""
|
||||
|
||||
# Subject is prominent - bold, bright white, no label needed
|
||||
self.subject_label.update(f"[b bright_white]{subject}[/b bright_white]")
|
||||
self.from_label.update(f"[b]From:[/b] {from_}")
|
||||
self.to_label.update(f"[b]To:[/b] {to}")
|
||||
|
||||
# Format the date for better readability
|
||||
if date:
|
||||
@@ -150,12 +258,12 @@ class EnvelopeHeader(Vertical):
|
||||
else:
|
||||
self.date_label.update("[b]Date:[/b] Unknown")
|
||||
|
||||
if cc:
|
||||
self.cc_label.update(f"[b]CC:[/b] {cc}")
|
||||
self.cc_label.styles.display = "block"
|
||||
else:
|
||||
if not cc:
|
||||
self.cc_label.styles.display = "none"
|
||||
|
||||
# Apply current display mode
|
||||
self._refresh_display()
|
||||
|
||||
|
||||
class ContentContainer(ScrollableContainer):
|
||||
"""Container for displaying email content with toggleable view modes."""
|
||||
|
||||
105
tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S
vendored
Normal file
105
tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
Content-Type: multipart/mixed; boundary="===============1234567890123456789=="
|
||||
MIME-Version: 1.0
|
||||
Message-ID: test-large-group-invite-001
|
||||
Subject: Project Kickoff Meeting
|
||||
From: Product Development <product.dev@example.com>
|
||||
To: Wolf, Taylor <taylor.wolf@example.com>, Marshall, Cody <cody.marshall@example.com>,
|
||||
Hernandez, Florencia <florencia.hernandez@example.com>, Santana, Jonatas <jonatas.santana@example.com>,
|
||||
Product Development <product.dev@example.com>
|
||||
Cc: Sevier, Josh <josh.sevier@example.com>, Rich, Melani <melani.rich@example.com>,
|
||||
Gardner, Doug <doug.gardner@example.com>, Young, Lindsey <lindsey.young@example.com>,
|
||||
Weathers, Robbie <robbie.weathers@example.com>, Wagner, Eric <eric.wagner@example.com>,
|
||||
Richardson, Adrian <adrian.richardson@example.com>, Roeschlein, Mitch <mitch.roeschlein@example.com>,
|
||||
Westphal, Bryan <bryan.westphal@example.com>, Jepsen, Gary <gary.jepsen@example.com>,
|
||||
Srinivasan, Sathya <sathya.srinivasan@example.com>, Bomani, Zenobia <zenobia.bomani@example.com>,
|
||||
Meyer, Andrew <andrew.meyer@example.com>, Stacy, Eric <eric.stacy@example.com>,
|
||||
Bitra, Avinash <avinash.bitra@example.com>, Alvarado, Joseph <joseph.alvarado@example.com>,
|
||||
Anderson, Pete <pete.anderson@example.com>, Modukuri, Savya <savya.modukuri@example.com>,
|
||||
Vazrala, Sowjanya <sowjanya.vazrala@example.com>, Bendt, Timothy <timothy.bendt@example.com>
|
||||
Date: Fri, 19 Dec 2025 21:42:58 +0000
|
||||
|
||||
--===============1234567890123456789==
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
MIME-Version: 1.0
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Project Kickoff Meetings: officially launches each project. Provides alignment for everyone involved with the project (project team, scrum team members, stakeholders).
|
||||
|
||||
* Present project's purpose, goals, and scope. This meeting should ensure a shared understanding and commitment to success, preventing misunderstandings, building momentum, and setting clear expectations for collaboration from day one.
|
||||
* Discuss possible subprojects and seasonal deliverables to meet commitments.
|
||||
* Required Attendees: Project Team, Contributing Scrum Team Members, & Product Owners
|
||||
* Optional Attendees: PDLT and Portfolio
|
||||
|
||||
Join the meeting: https://teams.microsoft.com/l/meetup-join/example
|
||||
|
||||
--===============1234567890123456789==
|
||||
Content-Type: text/calendar; charset="utf-8"; method=REQUEST
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
METHOD:REQUEST
|
||||
PRODID:Microsoft Exchange Server 2010
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Central Standard Time
|
||||
BEGIN:STANDARD
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0600
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0600
|
||||
TZOFFSETTO:-0500
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
ORGANIZER;CN="Product Development":mailto:product.dev@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Wolf, Taylor":mailto:taylor.wolf@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Marshall, Cody":mailto:cody.marshall@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Hernandez, Florencia":mailto:florencia.hernandez@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Santana, Jonatas":mailto:jonatas.santana@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Sevier, Josh":mailto:josh.sevier@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Rich, Melani":mailto:melani.rich@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Gardner, Doug":mailto:doug.gardner@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Young, Lindsey":mailto:lindsey.young@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Weathers, Robbie":mailto:robbie.weathers@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Wagner, Eric":mailto:eric.wagner@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Richardson, Adrian":mailto:adrian.richardson@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Roeschlein, Mitch":mailto:mitch.roeschlein@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Westphal, Bryan":mailto:bryan.westphal@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Jepsen, Gary":mailto:gary.jepsen@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Srinivasan, Sathya":mailto:sathya.srinivasan@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Bomani, Zenobia":mailto:zenobia.bomani@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Meyer, Andrew":mailto:andrew.meyer@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Stacy, Eric":mailto:eric.stacy@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Bitra, Avinash":mailto:avinash.bitra@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Alvarado, Joseph":mailto:joseph.alvarado@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Anderson, Pete":mailto:pete.anderson@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Modukuri, Savya":mailto:savya.modukuri@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Vazrala, Sowjanya":mailto:sowjanya.vazrala@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Bendt, Timothy":mailto:timothy.bendt@example.com
|
||||
UID:040000008200E00074C5B7101A82E0080000000004321F5267A12DA01000000000000000
|
||||
10000000030899396012345678968B934EDD6628570
|
||||
SUMMARY;LANGUAGE=en-US:Project Kickoff Meeting
|
||||
DTSTART;TZID=Central Standard Time:20251219T140000
|
||||
DTEND;TZID=Central Standard Time:20251219T150000
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
DTSTAMP:20251219T214258Z
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
SEQUENCE:0
|
||||
LOCATION;LANGUAGE=en-US:Microsoft Teams Meeting
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:0
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:0
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
||||
--===============1234567890123456789==--
|
||||
72
tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S
vendored
Normal file
72
tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
Content-Type: multipart/mixed; boundary="===============9876543210987654321=="
|
||||
MIME-Version: 1.0
|
||||
Message-ID: test-cancellation-001
|
||||
Subject: Canceled: Technical Refinement
|
||||
From: Marshall, Cody <cody.marshall@example.com>
|
||||
To: Ruttencutter, Chris <chris.ruttencutter@example.com>, Dake, Ryan <ryan.dake@example.com>,
|
||||
Smith, James <james.smith@example.com>, Santana, Jonatas <jonatas.santana@example.com>
|
||||
Cc: Bendt, Timothy <timothy.bendt@example.com>
|
||||
Date: Fri, 19 Dec 2025 19:12:46 +0000
|
||||
Importance: high
|
||||
X-Priority: 1
|
||||
|
||||
--===============9876543210987654321==
|
||||
Content-Type: text/plain; charset="us-ascii"
|
||||
MIME-Version: 1.0
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
The meeting has been cancelled.
|
||||
|
||||
--===============9876543210987654321==
|
||||
Content-Type: text/calendar; charset="utf-8"; method=CANCEL
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
METHOD:CANCEL
|
||||
PRODID:Microsoft Exchange Server 2010
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Central Standard Time
|
||||
BEGIN:STANDARD
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0600
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0600
|
||||
TZOFFSETTO:-0500
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
ORGANIZER;CN="Marshall, Cody":mailto:cody.marshall@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Ruttencutter, Chris":mailto:chris.ruttencutter@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Dake, Ryan":mailto:ryan.dake@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Smith, James":mailto:james.smith@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Santana, Jonatas":mailto:jonatas.santana@example.com
|
||||
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="Bendt, Timothy":mailto:timothy.bendt@example.com
|
||||
UID:040000008200E00074C5B7101A82E00800000000043F526712345678901000000000000000
|
||||
10000000308993960B03FD4C968B934EDD662857
|
||||
RECURRENCE-ID;TZID=Central Standard Time:20251224T133000
|
||||
SUMMARY;LANGUAGE=en-US:Canceled: Technical Refinement
|
||||
DTSTART;TZID=Central Standard Time:20251224T133000
|
||||
DTEND;TZID=Central Standard Time:20251224T140000
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:1
|
||||
DTSTAMP:20251219T191240Z
|
||||
TRANSP:TRANSPARENT
|
||||
STATUS:CANCELLED
|
||||
SEQUENCE:84
|
||||
LOCATION;LANGUAGE=en-US:Microsoft Teams Meeting
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:84
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:FREE
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:FREE
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:2
|
||||
X-MICROSOFT-CDO-INSTTYPE:3
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
||||
--===============9876543210987654321==--
|
||||
183
tests/test_invite_compressor.py
Normal file
183
tests/test_invite_compressor.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Tests for calendar invite compression."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from src.mail.invite_compressor import InviteCompressor, compress_invite
|
||||
from src.mail.utils.calendar_parser import (
|
||||
parse_calendar_from_raw_message,
|
||||
is_cancelled_event,
|
||||
is_event_request,
|
||||
)
|
||||
from src.mail.notification_detector import is_calendar_email
|
||||
|
||||
|
||||
class TestInviteDetection:
|
||||
"""Test detection of calendar invite emails."""
|
||||
|
||||
def test_detect_large_group_invite(self):
|
||||
"""Test detection of large group meeting invite."""
|
||||
fixture_path = Path(
|
||||
"tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S"
|
||||
)
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path, "r") as f:
|
||||
raw_message = f.read()
|
||||
|
||||
# Create envelope from message
|
||||
envelope = {
|
||||
"from": {"addr": "product.dev@example.com", "name": "Product Development"},
|
||||
"subject": "Project Kickoff Meeting",
|
||||
"date": "2025-12-19T21:42:58+00:00",
|
||||
}
|
||||
|
||||
# Should be detected as calendar email
|
||||
assert is_calendar_email(envelope) is True
|
||||
|
||||
# Parse the ICS
|
||||
event = parse_calendar_from_raw_message(raw_message)
|
||||
assert event is not None
|
||||
assert event.method == "REQUEST"
|
||||
assert is_event_request(event) is True
|
||||
assert event.summary == "Project Kickoff Meeting"
|
||||
assert len(event.attendees) >= 20 # Large group
|
||||
|
||||
def test_detect_cancellation(self):
|
||||
"""Test detection of meeting cancellation."""
|
||||
fixture_path = Path(
|
||||
"tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S"
|
||||
)
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path, "r") as f:
|
||||
raw_message = f.read()
|
||||
|
||||
envelope = {
|
||||
"from": {"addr": "cody.marshall@example.com", "name": "Marshall, Cody"},
|
||||
"subject": "Canceled: Technical Refinement",
|
||||
"date": "2025-12-19T19:12:46+00:00",
|
||||
}
|
||||
|
||||
# Should be detected as calendar email
|
||||
assert is_calendar_email(envelope) is True
|
||||
|
||||
# Parse the ICS
|
||||
event = parse_calendar_from_raw_message(raw_message)
|
||||
assert event is not None
|
||||
assert event.method == "CANCEL"
|
||||
assert is_cancelled_event(event) is True
|
||||
assert event.status == "CANCELLED"
|
||||
|
||||
|
||||
class TestInviteCompression:
|
||||
"""Test compression of calendar invite content."""
|
||||
|
||||
def test_compress_large_group_invite(self):
|
||||
"""Test compression of large group meeting invite."""
|
||||
fixture_path = Path(
|
||||
"tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S"
|
||||
)
|
||||
with open(fixture_path, "r") as f:
|
||||
raw_message = f.read()
|
||||
|
||||
envelope = {
|
||||
"from": {"addr": "product.dev@example.com", "name": "Product Development"},
|
||||
"subject": "Project Kickoff Meeting",
|
||||
"date": "2025-12-19T21:42:58+00:00",
|
||||
}
|
||||
|
||||
compressor = InviteCompressor(mode="summary")
|
||||
compressed, event = compressor.compress(raw_message, envelope)
|
||||
|
||||
assert event is not None
|
||||
assert "MEETING INVITE" in compressed
|
||||
assert "Project Kickoff Meeting" in compressed
|
||||
# Should show compressed attendee list
|
||||
assert "more)" in compressed # Truncated attendee list
|
||||
# Should show action hints for REQUEST
|
||||
assert "Accept" in compressed
|
||||
|
||||
def test_compress_cancellation(self):
|
||||
"""Test compression of meeting cancellation."""
|
||||
fixture_path = Path(
|
||||
"tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S"
|
||||
)
|
||||
with open(fixture_path, "r") as f:
|
||||
raw_message = f.read()
|
||||
|
||||
envelope = {
|
||||
"from": {"addr": "cody.marshall@example.com", "name": "Marshall, Cody"},
|
||||
"subject": "Canceled: Technical Refinement",
|
||||
"date": "2025-12-19T19:12:46+00:00",
|
||||
}
|
||||
|
||||
compressor = InviteCompressor(mode="summary")
|
||||
compressed, event = compressor.compress(raw_message, envelope)
|
||||
|
||||
assert event is not None
|
||||
assert "CANCELLED" in compressed
|
||||
# Title should be strikethrough (without the Canceled: prefix)
|
||||
assert "~~Technical Refinement~~" in compressed
|
||||
# Should NOT show action hints for cancelled meetings
|
||||
assert "Accept" not in compressed
|
||||
|
||||
def test_attendee_compression(self):
|
||||
"""Test attendee list compression."""
|
||||
compressor = InviteCompressor()
|
||||
|
||||
# Test with few attendees
|
||||
attendees = ["Alice <alice@example.com>", "Bob <bob@example.com>"]
|
||||
result = compressor._compress_attendees(attendees)
|
||||
assert result == "Alice, Bob"
|
||||
|
||||
# Test with many attendees
|
||||
many_attendees = [
|
||||
"Alice <alice@example.com>",
|
||||
"Bob <bob@example.com>",
|
||||
"Carol <carol@example.com>",
|
||||
"Dave <dave@example.com>",
|
||||
"Eve <eve@example.com>",
|
||||
]
|
||||
result = compressor._compress_attendees(many_attendees, max_shown=3)
|
||||
assert "Alice" in result
|
||||
assert "Bob" in result
|
||||
assert "Carol" in result
|
||||
assert "(+2 more)" in result
|
||||
|
||||
def test_compress_off_mode(self):
|
||||
"""Test that compression can be disabled."""
|
||||
fixture_path = Path(
|
||||
"tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S"
|
||||
)
|
||||
with open(fixture_path, "r") as f:
|
||||
raw_message = f.read()
|
||||
|
||||
envelope = {
|
||||
"from": {"addr": "product.dev@example.com"},
|
||||
"subject": "Project Kickoff Meeting",
|
||||
}
|
||||
|
||||
compressor = InviteCompressor(mode="off")
|
||||
assert compressor.should_compress(envelope) is False
|
||||
|
||||
compressed, event = compressor.compress(raw_message, envelope)
|
||||
assert compressed == ""
|
||||
assert event is None
|
||||
|
||||
def test_convenience_function(self):
|
||||
"""Test the compress_invite convenience function."""
|
||||
fixture_path = Path(
|
||||
"tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S"
|
||||
)
|
||||
with open(fixture_path, "r") as f:
|
||||
raw_message = f.read()
|
||||
|
||||
envelope = {
|
||||
"from": {"addr": "product.dev@example.com"},
|
||||
"subject": "Project Kickoff Meeting",
|
||||
}
|
||||
|
||||
compressed, event = compress_invite(raw_message, envelope)
|
||||
assert event is not None
|
||||
assert "Project Kickoff Meeting" in compressed
|
||||
Reference in New Issue
Block a user