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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user