From 16995a44655b6e86eacceda6c2e93e9b9d5bfdcc Mon Sep 17 00:00:00 2001 From: Bendt Date: Mon, 29 Dec 2025 10:53:19 -0500 Subject: [PATCH] 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 --- src/mail/app.py | 11 + src/mail/invite_compressor.py | 208 ++++++++++++++++++ src/mail/widgets/ContentContainer.py | 120 +++++++++- .../cur/17051227-large-group-invite.test:2,S | 105 +++++++++ .../INBOX/cur/17051228-cancellation.test:2,S | 72 ++++++ tests/test_invite_compressor.py | 183 +++++++++++++++ 6 files changed, 693 insertions(+), 6 deletions(-) create mode 100644 src/mail/invite_compressor.py create mode 100644 tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S create mode 100644 tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S create mode 100644 tests/test_invite_compressor.py diff --git a/src/mail/app.py b/src/mail/app.py index 3192536..5794bde 100644 --- a/src/mail/app.py +++ b/src/mail/app.py @@ -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 diff --git a/src/mail/invite_compressor.py b/src/mail/invite_compressor.py new file mode 100644 index 0000000..7051220 --- /dev/null +++ b/src/mail/invite_compressor.py @@ -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 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 " 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) diff --git a/src/mail/widgets/ContentContainer.py b/src/mail/widgets/ContentContainer.py index fb94d62..c1ca858 100644 --- a/src/mail/widgets/ContentContainer.py +++ b/src/mail/widgets/ContentContainer.py @@ -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 " 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 " 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.""" diff --git a/tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S b/tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S new file mode 100644 index 0000000..f8fb607 --- /dev/null +++ b/tests/fixtures/test_mailbox/INBOX/cur/17051227-large-group-invite.test:2,S @@ -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 +To: Wolf, Taylor , Marshall, Cody , + Hernandez, Florencia , Santana, Jonatas , + Product Development +Cc: Sevier, Josh , Rich, Melani , + Gardner, Doug , Young, Lindsey , + Weathers, Robbie , Wagner, Eric , + Richardson, Adrian , Roeschlein, Mitch , + Westphal, Bryan , Jepsen, Gary , + Srinivasan, Sathya , Bomani, Zenobia , + Meyer, Andrew , Stacy, Eric , + Bitra, Avinash , Alvarado, Joseph , + Anderson, Pete , Modukuri, Savya , + Vazrala, Sowjanya , Bendt, Timothy +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==-- diff --git a/tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S b/tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S new file mode 100644 index 0000000..04e5974 --- /dev/null +++ b/tests/fixtures/test_mailbox/INBOX/cur/17051228-cancellation.test:2,S @@ -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 +To: Ruttencutter, Chris , Dake, Ryan , + Smith, James , Santana, Jonatas +Cc: Bendt, Timothy +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==-- diff --git a/tests/test_invite_compressor.py b/tests/test_invite_compressor.py new file mode 100644 index 0000000..ef31673 --- /dev/null +++ b/tests/test_invite_compressor.py @@ -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 ", "Bob "] + result = compressor._compress_attendees(attendees) + assert result == "Alice, Bob" + + # Test with many attendees + many_attendees = [ + "Alice ", + "Bob ", + "Carol ", + "Dave ", + "Eve ", + ] + 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