feat: Detect and display Teams meeting emails without ICS attachments

- Fix is_calendar_email() to decode base64 MIME content before checking
- Add extract_teams_meeting_info() to parse meeting details from email body
- Update parse_calendar_from_raw_message() to fall back to Teams extraction
- Show 'Join Meeting' button for TEAMS method events in CalendarInvitePanel
- Extract Teams URL, Meeting ID, and organizer from email content
This commit is contained in:
Bendt
2026-01-06 16:07:07 -05:00
parent c71c506b84
commit f8a179e096
3 changed files with 191 additions and 6 deletions

View File

@@ -357,6 +357,43 @@ CALENDAR_SUBJECT_PATTERNS = [
]
def _decode_mime_content(raw_content: str) -> str:
"""Decode base64 parts from MIME content for text searching.
Args:
raw_content: Raw MIME message content
Returns:
Decoded text content for searching
"""
import base64
decoded_parts = [raw_content] # Include raw content for non-base64 parts
# Find and decode base64 text parts
b64_pattern = re.compile(
r"Content-Type:\s*text/(?:plain|html)[^\n]*\n"
r"(?:[^\n]+\n)*?" # Other headers
r"Content-Transfer-Encoding:\s*base64[^\n]*\n"
r"(?:[^\n]+\n)*?" # Other headers
r"\n" # Empty line before content
r"([A-Za-z0-9+/=\s]+)",
re.IGNORECASE,
)
for match in b64_pattern.finditer(raw_content):
try:
b64_content = (
match.group(1).replace("\n", "").replace("\r", "").replace(" ", "")
)
decoded = base64.b64decode(b64_content).decode("utf-8", errors="replace")
decoded_parts.append(decoded)
except Exception:
pass
return " ".join(decoded_parts)
def is_calendar_email(envelope: dict[str, Any], content: str | None = None) -> bool:
"""Check if an email is a calendar invite/update/cancellation.
@@ -388,12 +425,15 @@ def is_calendar_email(envelope: dict[str, Any], content: str | None = None) -> b
# If content is provided, check for calendar indicators
if content:
# Decode base64 parts for proper text searching
decoded_content = _decode_mime_content(content).lower()
# Teams meeting indicators
if "microsoft teams meeting" in content.lower():
if "microsoft teams meeting" in decoded_content:
return True
if "join the meeting" in content.lower():
if "join the meeting" in decoded_content:
return True
# ICS content indicator
# ICS content indicator (check raw content for MIME headers)
if "text/calendar" in content.lower():
return True
# VCALENDAR block

View File

@@ -233,19 +233,135 @@ def parse_ics_content(ics_content: str) -> Optional[ParsedCalendarEvent]:
return None
def _decode_mime_text(raw_message: str) -> str:
"""Decode base64 text parts from MIME message.
Args:
raw_message: Raw MIME message
Returns:
Decoded text content
"""
decoded_parts = []
# Find and decode base64 text parts
b64_pattern = re.compile(
r"Content-Type:\s*text/(?:plain|html)[^\n]*\n"
r"(?:[^\n]+\n)*?"
r"Content-Transfer-Encoding:\s*base64[^\n]*\n"
r"(?:[^\n]+\n)*?"
r"\n"
r"([A-Za-z0-9+/=\s]+)",
re.IGNORECASE,
)
for match in b64_pattern.finditer(raw_message):
try:
b64_content = (
match.group(1).replace("\n", "").replace("\r", "").replace(" ", "")
)
decoded = base64.b64decode(b64_content).decode("utf-8", errors="replace")
decoded_parts.append(decoded)
except Exception:
pass
return "\n".join(decoded_parts) if decoded_parts else raw_message
def extract_teams_meeting_info(raw_message: str) -> Optional[ParsedCalendarEvent]:
"""Extract Teams meeting info from email body when no ICS is present.
This handles emails that contain Teams meeting details in the body
but don't have an ICS calendar attachment.
Args:
raw_message: Full raw email in EML/MIME format
Returns:
ParsedCalendarEvent with Teams meeting info, or None if not a Teams meeting
"""
# Decode the message content
content = _decode_mime_text(raw_message)
content_lower = content.lower()
# Check if this is a Teams meeting email
if (
"microsoft teams" not in content_lower
and "join the meeting" not in content_lower
):
return None
# Extract Teams meeting URL
teams_url_pattern = re.compile(
r"https://teams\.microsoft\.com/l/meetup-join/[^\s<>\"']+",
re.IGNORECASE,
)
teams_url_match = teams_url_pattern.search(content)
teams_url = teams_url_match.group(0) if teams_url_match else None
# Extract meeting ID
meeting_id_pattern = re.compile(r"Meeting ID:\s*([\d\s]+)", re.IGNORECASE)
meeting_id_match = meeting_id_pattern.search(content)
meeting_id = meeting_id_match.group(1).strip() if meeting_id_match else None
# Extract subject from email headers
subject = None
subject_match = re.search(
r"^Subject:\s*(.+)$", raw_message, re.MULTILINE | re.IGNORECASE
)
if subject_match:
subject = subject_match.group(1).strip()
# Extract organizer from From header
organizer_email = None
organizer_name = None
from_match = re.search(r"^From:\s*(.+)$", raw_message, re.MULTILINE | re.IGNORECASE)
if from_match:
from_value = from_match.group(1).strip()
# Parse "Name <email>" format
email_match = re.search(r"<([^>]+)>", from_value)
if email_match:
organizer_email = email_match.group(1)
organizer_name = from_value.split("<")[0].strip().strip('"')
else:
organizer_email = from_value
# Create location string with Teams info
location = teams_url if teams_url else "Microsoft Teams Meeting"
if meeting_id:
location = f"Teams Meeting (ID: {meeting_id})"
return ParsedCalendarEvent(
summary=subject or "Teams Meeting",
location=location,
description=f"Join: {teams_url}" if teams_url else None,
method="TEAMS", # Custom method to indicate this is extracted, not from ICS
organizer_name=organizer_name,
organizer_email=organizer_email,
)
def parse_calendar_from_raw_message(raw_message: str) -> Optional[ParsedCalendarEvent]:
"""Extract and parse calendar event from raw email message.
First tries to extract ICS content from the message. If no ICS is found,
falls back to extracting Teams meeting info from the email body.
Args:
raw_message: Full raw email in EML/MIME format
Returns:
ParsedCalendarEvent if found and parsed, None otherwise
"""
# First try to extract ICS content
ics_content = extract_ics_from_mime(raw_message)
if ics_content:
return parse_ics_content(ics_content)
return None
event = parse_ics_content(ics_content)
if event:
return event
# Fall back to extracting Teams meeting info from body
return extract_teams_meeting_info(raw_message)
# Legacy function names for compatibility

View File

@@ -132,7 +132,7 @@ class CalendarInvitePanel(Vertical):
classes="event-detail",
)
# Action buttons (only for REQUEST method, not for CANCEL)
# Action buttons (only for REQUEST method, not for CANCEL or TEAMS)
if is_event_request(self.event):
with Horizontal(classes="action-buttons"):
yield Button(
@@ -150,6 +150,20 @@ class CalendarInvitePanel(Vertical):
id="btn-decline",
variant="error",
)
elif self.event.method == "TEAMS":
# Teams meeting extracted from email body (no ICS)
# Show join button if we have a URL in the description
if self.event.description and "Join:" in self.event.description:
with Horizontal(classes="action-buttons"):
yield Button(
"\uf0c1 Join Meeting", # nf-fa-link
id="btn-join",
variant="primary",
)
yield Static(
"[dim]Teams meeting - no calendar invite attached[/dim]",
classes="event-detail",
)
elif is_cancelled_event(self.event):
yield Static(
"[dim]This meeting has been cancelled[/dim]",
@@ -164,6 +178,8 @@ class CalendarInvitePanel(Vertical):
return "CANCELLED", "cancelled"
elif method == "REQUEST":
return "INVITE", "request"
elif method == "TEAMS":
return "TEAMS", "request"
elif method == "REPLY":
return "REPLY", "reply"
elif method == "COUNTER":
@@ -188,3 +204,16 @@ class CalendarInvitePanel(Vertical):
self.post_message(self.InviteAction("tentative", self.event))
elif button_id == "btn-decline":
self.post_message(self.InviteAction("decline", self.event))
elif button_id == "btn-join":
# Open Teams meeting URL
if self.event.description and "Join:" in self.event.description:
import re
import subprocess
url_match = re.search(
r"Join:\s*(https://[^\s]+)", self.event.description
)
if url_match:
url = url_match.group(1)
subprocess.run(["open", url], capture_output=True)
self.app.notify("Opening Teams meeting...", severity="information")