feat: Scrollable envelope header with proper To/CC display
- Refactor ContentContainer to Vertical layout with fixed header + scrollable content - Change EnvelopeHeader to ScrollableContainer for long recipient lists - Parse headers from message content (fixes empty To: field from himalaya) - Strip all email headers, MIME boundaries, base64 blocks from body display - Add 22 unit tests for header parsing and content stripping - Cancelled meeting emails now render with empty body as expected
This commit is contained in:
@@ -901,19 +901,19 @@ class EmailViewerApp(App):
|
|||||||
|
|
||||||
def action_scroll_down(self) -> None:
|
def action_scroll_down(self) -> None:
|
||||||
"""Scroll the main content down."""
|
"""Scroll the main content down."""
|
||||||
self.query_one("#main_content").scroll_down()
|
self.query_one("#content_scroll").scroll_down()
|
||||||
|
|
||||||
def action_scroll_up(self) -> None:
|
def action_scroll_up(self) -> None:
|
||||||
"""Scroll the main content up."""
|
"""Scroll the main content up."""
|
||||||
self.query_one("#main_content").scroll_up()
|
self.query_one("#content_scroll").scroll_up()
|
||||||
|
|
||||||
def action_scroll_page_down(self) -> None:
|
def action_scroll_page_down(self) -> None:
|
||||||
"""Scroll the main content down by a page."""
|
"""Scroll the main content down by a page."""
|
||||||
self.query_one("#main_content").scroll_page_down()
|
self.query_one("#content_scroll").scroll_page_down()
|
||||||
|
|
||||||
def action_scroll_page_up(self) -> None:
|
def action_scroll_page_up(self) -> None:
|
||||||
"""Scroll the main content up by a page."""
|
"""Scroll the main content up by a page."""
|
||||||
self.query_one("#main_content").scroll_page_up()
|
self.query_one("#content_scroll").scroll_page_up()
|
||||||
|
|
||||||
def action_toggle_header(self) -> None:
|
def action_toggle_header(self) -> None:
|
||||||
"""Toggle between compressed and full envelope headers."""
|
"""Toggle between compressed and full envelope headers."""
|
||||||
|
|||||||
@@ -71,12 +71,23 @@ StatusTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnvelopeHeader {
|
EnvelopeHeader {
|
||||||
dock: top;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 10;
|
min-height: 4;
|
||||||
|
max-height: 6;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
tint: $primary 10%;
|
background: $surface-darken-1;
|
||||||
|
scrollbar-size: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full headers mode - allow more height for scrolling */
|
||||||
|
EnvelopeHeader.full-headers {
|
||||||
|
max-height: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_scroll {
|
||||||
|
height: 1fr;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header labels should be single line with truncation */
|
/* Header labels should be single line with truncation */
|
||||||
|
|||||||
@@ -112,8 +112,11 @@ def compress_urls_in_content(content: str, max_url_len: int = 50) -> str:
|
|||||||
return "".join(result)
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
class EnvelopeHeader(Vertical):
|
class EnvelopeHeader(ScrollableContainer):
|
||||||
"""Email envelope header with compressible To/CC fields."""
|
"""Email envelope header with compressible To/CC fields.
|
||||||
|
|
||||||
|
Scrollable when in full-headers mode to handle long recipient lists.
|
||||||
|
"""
|
||||||
|
|
||||||
# Maximum recipients to show before truncating
|
# Maximum recipients to show before truncating
|
||||||
MAX_RECIPIENTS_SHOWN = 2
|
MAX_RECIPIENTS_SHOWN = 2
|
||||||
@@ -134,7 +137,6 @@ class EnvelopeHeader(Vertical):
|
|||||||
self._full_from = ""
|
self._full_from = ""
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
self.styles.height = "auto"
|
|
||||||
self.mount(self.subject_label)
|
self.mount(self.subject_label)
|
||||||
self.mount(self.from_label)
|
self.mount(self.from_label)
|
||||||
self.mount(self.to_label)
|
self.mount(self.to_label)
|
||||||
@@ -142,6 +144,13 @@ class EnvelopeHeader(Vertical):
|
|||||||
self.mount(self.date_label)
|
self.mount(self.date_label)
|
||||||
# Add bottom margin to subject for visual separation from metadata
|
# Add bottom margin to subject for visual separation from metadata
|
||||||
self.subject_label.styles.margin = (0, 0, 1, 0)
|
self.subject_label.styles.margin = (0, 0, 1, 0)
|
||||||
|
# Hide CC label by default (shown when CC is present)
|
||||||
|
self.cc_label.styles.display = "none"
|
||||||
|
# Set initial placeholder content
|
||||||
|
self.subject_label.update("[dim]Select a message to view[/dim]")
|
||||||
|
self.from_label.update("[b]From:[/b] -")
|
||||||
|
self.to_label.update("[b]To:[/b] -")
|
||||||
|
self.date_label.update("[b]Date:[/b] -")
|
||||||
|
|
||||||
def _compress_recipients(self, recipients_str: str, max_shown: int = 2) -> str:
|
def _compress_recipients(self, recipients_str: str, max_shown: int = 2) -> str:
|
||||||
"""Compress a list of recipients to a single line with truncation.
|
"""Compress a list of recipients to a single line with truncation.
|
||||||
@@ -276,8 +285,13 @@ class EnvelopeHeader(Vertical):
|
|||||||
self._refresh_display()
|
self._refresh_display()
|
||||||
|
|
||||||
|
|
||||||
class ContentContainer(ScrollableContainer):
|
class ContentContainer(Vertical):
|
||||||
"""Container for displaying email content with toggleable view modes."""
|
"""Container for displaying email content with toggleable view modes.
|
||||||
|
|
||||||
|
Uses a Vertical layout with:
|
||||||
|
- EnvelopeHeader (fixed at top, non-scrolling)
|
||||||
|
- ScrollableContainer for the message content (scrollable)
|
||||||
|
"""
|
||||||
|
|
||||||
can_focus = True
|
can_focus = True
|
||||||
|
|
||||||
@@ -290,6 +304,7 @@ class ContentContainer(ScrollableContainer):
|
|||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.md = MarkItDown()
|
self.md = MarkItDown()
|
||||||
self.header = EnvelopeHeader(id="envelope_header")
|
self.header = EnvelopeHeader(id="envelope_header")
|
||||||
|
self.scroll_container = ScrollableContainer(id="content_scroll")
|
||||||
self.content = Markdown("", id="markdown_content")
|
self.content = Markdown("", id="markdown_content")
|
||||||
self.html_content = Static("", id="html_content", markup=False)
|
self.html_content = Static("", id="html_content", markup=False)
|
||||||
self.current_content = None
|
self.current_content = None
|
||||||
@@ -314,8 +329,9 @@ class ContentContainer(ScrollableContainer):
|
|||||||
|
|
||||||
def compose(self):
|
def compose(self):
|
||||||
yield self.header
|
yield self.header
|
||||||
yield self.content
|
with self.scroll_container:
|
||||||
yield self.html_content
|
yield self.content
|
||||||
|
yield self.html_content
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
# Set initial display based on config default
|
# Set initial display based on config default
|
||||||
@@ -416,6 +432,36 @@ class ContentContainer(ScrollableContainer):
|
|||||||
self.current_account = account
|
self.current_account = account
|
||||||
self.current_envelope = envelope
|
self.current_envelope = envelope
|
||||||
|
|
||||||
|
# Update the header with envelope data
|
||||||
|
if envelope:
|
||||||
|
subject = envelope.get("subject", "")
|
||||||
|
|
||||||
|
# Extract from - can be dict with name/addr or string
|
||||||
|
from_info = envelope.get("from", {})
|
||||||
|
if isinstance(from_info, dict):
|
||||||
|
from_name = from_info.get("name") or ""
|
||||||
|
from_addr = from_info.get("addr") or ""
|
||||||
|
if from_name and from_addr:
|
||||||
|
from_str = f"{from_name} <{from_addr}>"
|
||||||
|
elif from_name:
|
||||||
|
from_str = from_name
|
||||||
|
else:
|
||||||
|
from_str = from_addr
|
||||||
|
else:
|
||||||
|
from_str = str(from_info)
|
||||||
|
|
||||||
|
# Extract to - can be dict, list of dicts, or string
|
||||||
|
to_info = envelope.get("to", {})
|
||||||
|
to_str = self._format_recipients(to_info)
|
||||||
|
|
||||||
|
# Extract cc - can be dict, list of dicts, or string
|
||||||
|
cc_info = envelope.get("cc", {})
|
||||||
|
cc_str = self._format_recipients(cc_info) if cc_info else None
|
||||||
|
|
||||||
|
date = envelope.get("date", "")
|
||||||
|
|
||||||
|
self.header.update(subject, from_str, to_str, date, cc_str)
|
||||||
|
|
||||||
# Immediately show a loading message
|
# Immediately show a loading message
|
||||||
if self.current_mode == "markdown":
|
if self.current_mode == "markdown":
|
||||||
self.content.update("Loading...")
|
self.content.update("Loading...")
|
||||||
@@ -430,6 +476,158 @@ class ContentContainer(ScrollableContainer):
|
|||||||
format_type = "text" if self.current_mode == "markdown" else "html"
|
format_type = "text" if self.current_mode == "markdown" else "html"
|
||||||
self.content_worker = self.fetch_message_content(message_id, format_type)
|
self.content_worker = self.fetch_message_content(message_id, format_type)
|
||||||
|
|
||||||
|
def _strip_headers_from_content(self, content: str) -> str:
|
||||||
|
"""Strip email headers and multipart MIME noise from content.
|
||||||
|
|
||||||
|
Email content from himalaya may include:
|
||||||
|
1. Headers at the top (From, To, Subject, etc.) - shown in EnvelopeHeader
|
||||||
|
2. Additional full headers after a blank line (Received, etc.)
|
||||||
|
3. MIME multipart boundaries and part headers
|
||||||
|
4. Base64 encoded content (attachments, calendar data)
|
||||||
|
|
||||||
|
This extracts just the readable plain text content.
|
||||||
|
"""
|
||||||
|
lines = content.split("\n")
|
||||||
|
result_lines = []
|
||||||
|
in_base64_block = False
|
||||||
|
in_calendar_block = False
|
||||||
|
in_header_block = True # Start assuming we're in headers
|
||||||
|
|
||||||
|
# Common email header patterns (case insensitive)
|
||||||
|
header_pattern = re.compile(
|
||||||
|
r"^(From|To|Subject|Date|CC|BCC|Reply-To|Message-ID|Received|"
|
||||||
|
r"Content-Type|Content-Transfer-Encoding|Content-Disposition|"
|
||||||
|
r"Content-Language|MIME-Version|Thread-Topic|Thread-Index|"
|
||||||
|
r"Importance|X-Priority|Accept-Language|X-MS-|x-ms-|"
|
||||||
|
r"x-microsoft-|x-forefront-|authentication-results).*:",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Skip MIME boundary lines (--boundary or --boundary--)
|
||||||
|
if stripped.startswith("--") and len(stripped) > 10:
|
||||||
|
in_base64_block = False
|
||||||
|
in_header_block = False # After boundary, might be content
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for Content-Type to detect base64/calendar sections
|
||||||
|
if stripped.lower().startswith("content-type:"):
|
||||||
|
if (
|
||||||
|
"base64" in stripped.lower() or "base64" in lines[i + 1].lower()
|
||||||
|
if i + 1 < len(lines)
|
||||||
|
else False
|
||||||
|
):
|
||||||
|
in_base64_block = True
|
||||||
|
if "text/calendar" in stripped.lower():
|
||||||
|
in_calendar_block = True
|
||||||
|
# Skip this header and any continuation lines
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and (
|
||||||
|
lines[i].startswith(" ") or lines[i].startswith("\t")
|
||||||
|
):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip Content-Transfer-Encoding header
|
||||||
|
if stripped.lower().startswith("content-transfer-encoding:"):
|
||||||
|
if "base64" in stripped.lower():
|
||||||
|
in_base64_block = True
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip email headers (matches header pattern)
|
||||||
|
if header_pattern.match(line):
|
||||||
|
# Skip this header and any continuation lines
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and (
|
||||||
|
lines[i].startswith(" ") or lines[i].startswith("\t")
|
||||||
|
):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Blank line - could be end of headers or part separator
|
||||||
|
if stripped == "":
|
||||||
|
# If we haven't collected any content yet, keep skipping
|
||||||
|
if not result_lines:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
# Otherwise keep the blank line (paragraph separator in body)
|
||||||
|
result_lines.append(line)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Detect and skip base64 encoded blocks
|
||||||
|
if in_base64_block:
|
||||||
|
# Check if line looks like base64 (long string of base64 chars)
|
||||||
|
if len(stripped) > 20 and re.match(r"^[A-Za-z0-9+/=]+$", stripped):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# End of base64 block
|
||||||
|
in_base64_block = False
|
||||||
|
|
||||||
|
# Skip calendar/ICS content (BEGIN:VCALENDAR to END:VCALENDAR)
|
||||||
|
if stripped.startswith("BEGIN:VCALENDAR"):
|
||||||
|
in_calendar_block = True
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if in_calendar_block:
|
||||||
|
if stripped.startswith("END:VCALENDAR"):
|
||||||
|
in_calendar_block = False
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# This looks like actual content - add it
|
||||||
|
result_lines.append(line)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return "\n".join(result_lines).strip()
|
||||||
|
|
||||||
|
return "\n".join(result_lines).strip()
|
||||||
|
|
||||||
|
def _format_recipients(self, recipients_info) -> str:
|
||||||
|
"""Format recipients info (dict, list of dicts, or string) to a string."""
|
||||||
|
if not recipients_info:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if isinstance(recipients_info, str):
|
||||||
|
return recipients_info
|
||||||
|
|
||||||
|
if isinstance(recipients_info, dict):
|
||||||
|
# Single recipient
|
||||||
|
name = recipients_info.get("name") or ""
|
||||||
|
addr = recipients_info.get("addr") or ""
|
||||||
|
if name and addr:
|
||||||
|
return f"{name} <{addr}>"
|
||||||
|
elif name:
|
||||||
|
return name
|
||||||
|
else:
|
||||||
|
return addr
|
||||||
|
|
||||||
|
if isinstance(recipients_info, list):
|
||||||
|
# Multiple recipients
|
||||||
|
parts = []
|
||||||
|
for r in recipients_info:
|
||||||
|
if isinstance(r, dict):
|
||||||
|
name = r.get("name") or ""
|
||||||
|
addr = r.get("addr") or ""
|
||||||
|
if name and addr:
|
||||||
|
parts.append(f"{name} <{addr}>")
|
||||||
|
elif name:
|
||||||
|
parts.append(name)
|
||||||
|
elif addr:
|
||||||
|
parts.append(addr)
|
||||||
|
elif isinstance(r, str):
|
||||||
|
parts.append(r)
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
return str(recipients_info)
|
||||||
|
|
||||||
def clear_content(self) -> None:
|
def clear_content(self) -> None:
|
||||||
"""Clear the message content display."""
|
"""Clear the message content display."""
|
||||||
self.content.update("")
|
self.content.update("")
|
||||||
@@ -438,11 +636,75 @@ class ContentContainer(ScrollableContainer):
|
|||||||
self.current_message_id = None
|
self.current_message_id = None
|
||||||
self.border_title = "No message selected"
|
self.border_title = "No message selected"
|
||||||
|
|
||||||
|
def _parse_headers_from_content(self, content: str) -> Dict[str, str]:
|
||||||
|
"""Parse email headers from message content.
|
||||||
|
|
||||||
|
Returns a dict with keys: from, to, subject, date, cc
|
||||||
|
"""
|
||||||
|
headers = {}
|
||||||
|
lines = content.split("\n")
|
||||||
|
current_header = None
|
||||||
|
current_value = ""
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# Blank line marks end of headers
|
||||||
|
if line.strip() == "":
|
||||||
|
if current_header:
|
||||||
|
headers[current_header] = current_value.strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for header continuation (line starts with whitespace)
|
||||||
|
if line.startswith(" ") or line.startswith("\t"):
|
||||||
|
if current_header:
|
||||||
|
current_value += " " + line.strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save previous header if any
|
||||||
|
if current_header:
|
||||||
|
headers[current_header] = current_value.strip()
|
||||||
|
|
||||||
|
# Parse new header
|
||||||
|
if ":" in line:
|
||||||
|
header_name, _, value = line.partition(":")
|
||||||
|
header_lower = header_name.lower().strip()
|
||||||
|
if header_lower in ("from", "to", "subject", "date", "cc"):
|
||||||
|
current_header = header_lower
|
||||||
|
current_value = value.strip()
|
||||||
|
else:
|
||||||
|
current_header = None
|
||||||
|
else:
|
||||||
|
# Line doesn't look like a header, we've reached body
|
||||||
|
break
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
def _update_content(self, content: str | None) -> None:
|
def _update_content(self, content: str | None) -> None:
|
||||||
"""Update the content widgets with the fetched content."""
|
"""Update the content widgets with the fetched content."""
|
||||||
if content is None:
|
if content is None:
|
||||||
content = "(No content)"
|
content = "(No content)"
|
||||||
|
|
||||||
|
# Parse headers from content to update the EnvelopeHeader
|
||||||
|
# (himalaya envelope list doesn't include full To/CC info)
|
||||||
|
parsed_headers = self._parse_headers_from_content(content)
|
||||||
|
if parsed_headers:
|
||||||
|
# Update header with parsed values, falling back to envelope data
|
||||||
|
subject = parsed_headers.get("subject") or (
|
||||||
|
self.current_envelope.get("subject", "")
|
||||||
|
if self.current_envelope
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
from_str = parsed_headers.get("from") or ""
|
||||||
|
to_str = parsed_headers.get("to") or ""
|
||||||
|
date_str = parsed_headers.get("date") or (
|
||||||
|
self.current_envelope.get("date", "") if self.current_envelope else ""
|
||||||
|
)
|
||||||
|
cc_str = parsed_headers.get("cc") or None
|
||||||
|
|
||||||
|
self.header.update(subject, from_str, to_str, date_str, cc_str)
|
||||||
|
|
||||||
|
# Strip headers from content (they're shown in EnvelopeHeader)
|
||||||
|
content = self._strip_headers_from_content(content)
|
||||||
|
|
||||||
# Store the raw content for link extraction
|
# Store the raw content for link extraction
|
||||||
self.current_content = content
|
self.current_content = content
|
||||||
|
|
||||||
@@ -507,13 +769,13 @@ class ContentContainer(ScrollableContainer):
|
|||||||
return extract_links_from_content(self.current_content)
|
return extract_links_from_content(self.current_content)
|
||||||
|
|
||||||
def _show_calendar_panel(self, event: ParsedCalendarEvent) -> None:
|
def _show_calendar_panel(self, event: ParsedCalendarEvent) -> None:
|
||||||
"""Show the calendar invite panel at the top of the content."""
|
"""Show the calendar invite panel at the top of the scrollable content."""
|
||||||
# Remove existing panel if any
|
# Remove existing panel if any
|
||||||
self._hide_calendar_panel()
|
self._hide_calendar_panel()
|
||||||
|
|
||||||
# Create and mount new panel at the beginning
|
# Create and mount new panel at the beginning of the scroll container
|
||||||
self.calendar_panel = CalendarInvitePanel(event, id="calendar_invite_panel")
|
self.calendar_panel = CalendarInvitePanel(event, id="calendar_invite_panel")
|
||||||
self.mount(self.calendar_panel, before=0)
|
self.scroll_container.mount(self.calendar_panel, before=0)
|
||||||
|
|
||||||
def _hide_calendar_panel(self) -> None:
|
def _hide_calendar_panel(self) -> None:
|
||||||
"""Hide/remove the calendar invite panel."""
|
"""Hide/remove the calendar invite panel."""
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ async def get_message_content(
|
|||||||
- Success status (True if operation was successful)
|
- Success status (True if operation was successful)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Don't use --no-headers - we parse headers for EnvelopeHeader display
|
||||||
|
# and strip them from the body content ourselves
|
||||||
cmd = f"himalaya message read {message_id}"
|
cmd = f"himalaya message read {message_id}"
|
||||||
if folder:
|
if folder:
|
||||||
cmd += f" -f '{folder}'"
|
cmd += f" -f '{folder}'"
|
||||||
|
|||||||
375
tests/test_header_parsing.py
Normal file
375
tests/test_header_parsing.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
"""Unit tests for email header parsing from message content.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
pytest tests/test_header_parsing.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path for proper imports
|
||||||
|
project_root = str(Path(__file__).parent.parent)
|
||||||
|
if project_root not in sys.path:
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
|
||||||
|
# Sample cancelled meeting email from himalaya (message 114)
|
||||||
|
CANCELLED_MEETING_EMAIL = """From: Marshall <unknown>, Cody <john.marshall@corteva.com>
|
||||||
|
To: Ruttencutter <unknown>, Chris <chris.ruttencutter@corteva.com>, Dake <unknown>, Ryan <ryan.dake@corteva.com>, Smith <unknown>, James <james.l.smith@corteva.com>, Santana <unknown>, Jonatas <jonatas.santana@corteva.com>
|
||||||
|
Cc: Bendt <unknown>, Timothy <timothy.bendt@corteva.com>
|
||||||
|
Subject: Canceled: Technical Refinement
|
||||||
|
|
||||||
|
Received: from CY8PR17MB7060.namprd17.prod.outlook.com (2603:10b6:930:6d::6)
|
||||||
|
by PH7PR17MB7149.namprd17.prod.outlook.com with HTTPS; Fri, 19 Dec 2025
|
||||||
|
19:12:45 +0000
|
||||||
|
Received: from SA6PR17MB7362.namprd17.prod.outlook.com (2603:10b6:806:411::6)
|
||||||
|
by CY8PR17MB7060.namprd17.prod.outlook.com (2603:10b6:930:6d::6) with
|
||||||
|
Microsoft SMTP Server (version=TLS1_2,
|
||||||
|
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.9434.8; Fri, 19 Dec
|
||||||
|
2025 19:12:42 +0000
|
||||||
|
From: "Marshall, Cody" <john.marshall@corteva.com>
|
||||||
|
To: "Ruttencutter, Chris" <chris.ruttencutter@corteva.com>, "Dake, Ryan"
|
||||||
|
<ryan.dake@corteva.com>, "Smith, James" <james.l.smith@corteva.com>,
|
||||||
|
"Santana, Jonatas" <jonatas.santana@corteva.com>
|
||||||
|
CC: "Bendt, Timothy" <timothy.bendt@corteva.com>
|
||||||
|
Subject: Canceled: Technical Refinement
|
||||||
|
Thread-Topic: Technical Refinement
|
||||||
|
Thread-Index: AdoSeicQGeYQHp7iHUWAUBWrOGskKw==
|
||||||
|
Importance: high
|
||||||
|
X-Priority: 1
|
||||||
|
Date: Fri, 19 Dec 2025 19:12:42 +0000
|
||||||
|
Message-ID:
|
||||||
|
<SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362.namprd17.prod.outlook.com>
|
||||||
|
Accept-Language: en-US
|
||||||
|
Content-Language: en-US
|
||||||
|
X-MS-Exchange-Organization-AuthAs: Internal
|
||||||
|
X-MS-Exchange-Organization-AuthMechanism: 04
|
||||||
|
Content-Type: multipart/alternative;
|
||||||
|
boundary="_002_SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362namp_"
|
||||||
|
MIME-Version: 1.0
|
||||||
|
|
||||||
|
--_002_SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362namp_
|
||||||
|
Content-Type: text/plain; charset="us-ascii"
|
||||||
|
|
||||||
|
|
||||||
|
--_002_SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362namp_
|
||||||
|
Content-Type: text/calendar; charset="utf-8"; method=CANCEL
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
QkVHSU46VkNBTEVOREFSDQpNRVRIT0Q6Q0FOQ0VMDQpQUk9ESUQ6TWljcm9zb2Z0IEV4Y2hhbmdl
|
||||||
|
IFNlcnZlciAyMDEwDQpWRVJTSU9OOjIuMA0KQkVHSU46VlRJTUVaT05FDQpUWklEOkNlbnRyYWwg
|
||||||
|
U3RhbmRhcmQgVGltZQ0KQkVHSU46U1RBTkRBUkQNCkRUU1RBUlQ6MTYwMTAxMDFUMDIwMDAwDQpU
|
||||||
|
|
||||||
|
--_002_SA6PR17MB7362D5E1A906728B63A804D2E4A9ASA6PR17MB7362namp_--
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseHeadersFromContent:
|
||||||
|
"""Tests for _parse_headers_from_content method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def parser(self):
|
||||||
|
"""Create a ContentContainer instance for testing."""
|
||||||
|
# We need to create a minimal instance just for the parsing method
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from src.mail.widgets.ContentContainer import ContentContainer
|
||||||
|
|
||||||
|
container = ContentContainer()
|
||||||
|
return container._parse_headers_from_content
|
||||||
|
|
||||||
|
def test_parse_simple_headers(self, parser):
|
||||||
|
"""Test parsing simple email headers."""
|
||||||
|
content = """From: John Doe <john@example.com>
|
||||||
|
To: Jane Smith <jane@example.com>
|
||||||
|
Subject: Test Email
|
||||||
|
Date: Mon, 29 Dec 2025 10:00:00 +0000
|
||||||
|
|
||||||
|
This is the body of the email.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert headers["from"] == "John Doe <john@example.com>"
|
||||||
|
assert headers["to"] == "Jane Smith <jane@example.com>"
|
||||||
|
assert headers["subject"] == "Test Email"
|
||||||
|
assert headers["date"] == "Mon, 29 Dec 2025 10:00:00 +0000"
|
||||||
|
|
||||||
|
def test_parse_multiple_recipients(self, parser):
|
||||||
|
"""Test parsing headers with multiple recipients."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
To: user1@example.com, user2@example.com, user3@example.com
|
||||||
|
Subject: Multi-recipient email
|
||||||
|
Date: 2025-12-29
|
||||||
|
|
||||||
|
Body here.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
headers["to"] == "user1@example.com, user2@example.com, user3@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_with_cc(self, parser):
|
||||||
|
"""Test parsing headers including CC."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
To: recipient@example.com
|
||||||
|
CC: cc1@example.com, cc2@example.com
|
||||||
|
Subject: Email with CC
|
||||||
|
Date: 2025-12-29
|
||||||
|
|
||||||
|
Body.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert headers["to"] == "recipient@example.com"
|
||||||
|
assert headers["cc"] == "cc1@example.com, cc2@example.com"
|
||||||
|
|
||||||
|
def test_parse_multiline_to_header(self, parser):
|
||||||
|
"""Test parsing To header that spans multiple lines."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
To: First User <first@example.com>,
|
||||||
|
Second User <second@example.com>,
|
||||||
|
Third User <third@example.com>
|
||||||
|
Subject: Multi-line To
|
||||||
|
Date: 2025-12-29
|
||||||
|
|
||||||
|
Body.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
# Should combine continuation lines
|
||||||
|
assert "First User" in headers["to"]
|
||||||
|
assert "Second User" in headers["to"]
|
||||||
|
assert "Third User" in headers["to"]
|
||||||
|
|
||||||
|
def test_parse_with_name_and_email(self, parser):
|
||||||
|
"""Test parsing headers with display names and email addresses."""
|
||||||
|
content = """From: Renovate Bot (SA @renovate-bot-sa) <gitlab@example.com>
|
||||||
|
To: Bendt <unknown>, Timothy <timothy.bendt@example.com>
|
||||||
|
Subject: Test Subject
|
||||||
|
Date: 2025-12-29 02:07+00:00
|
||||||
|
|
||||||
|
Body content.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
headers["from"] == "Renovate Bot (SA @renovate-bot-sa) <gitlab@example.com>"
|
||||||
|
)
|
||||||
|
assert "Timothy <timothy.bendt@example.com>" in headers["to"]
|
||||||
|
assert "Bendt <unknown>" in headers["to"]
|
||||||
|
|
||||||
|
def test_parse_empty_content(self, parser):
|
||||||
|
"""Test parsing empty content."""
|
||||||
|
headers = parser("")
|
||||||
|
assert headers == {}
|
||||||
|
|
||||||
|
def test_parse_no_headers(self, parser):
|
||||||
|
"""Test parsing content with no recognizable headers."""
|
||||||
|
content = """This is just body content
|
||||||
|
without any headers.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
assert headers == {}
|
||||||
|
|
||||||
|
def test_parse_ignores_unknown_headers(self, parser):
|
||||||
|
"""Test that unknown headers are ignored."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
X-Custom-Header: some value
|
||||||
|
To: recipient@example.com
|
||||||
|
Message-ID: <123@example.com>
|
||||||
|
Subject: Test
|
||||||
|
Date: 2025-12-29
|
||||||
|
|
||||||
|
Body.
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
# Should only have the recognized headers
|
||||||
|
assert set(headers.keys()) == {"from", "to", "subject", "date"}
|
||||||
|
assert "X-Custom-Header" not in headers
|
||||||
|
assert "Message-ID" not in headers
|
||||||
|
|
||||||
|
def test_parse_real_himalaya_output(self, parser):
|
||||||
|
"""Test parsing actual himalaya message read output format."""
|
||||||
|
content = """From: Renovate Bot (SA @renovate-bot-sa) <gitlab@gitlab.research.corteva.com>
|
||||||
|
To: Bendt <unknown>, Timothy <timothy.bendt@corteva.com>
|
||||||
|
Subject: Re: Fabric3 Monorepo | chore(deps): update vitest monorepo to v4 (major) (!6861)
|
||||||
|
|
||||||
|
Renovate Bot (SA) pushed new commits to merge request !6861<https://gitlab.research.corteva.com/granular/fabric/fabric3/-/merge_requests/6861>
|
||||||
|
|
||||||
|
* f96fec2b...2fb2ae10 <https://gitlab.research.corteva.com/granular/fabric/fabric3/-/compare/f96fec2b...2fb2ae10> - 2 commits from branch `main`
|
||||||
|
"""
|
||||||
|
headers = parser(content)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
headers["from"]
|
||||||
|
== "Renovate Bot (SA @renovate-bot-sa) <gitlab@gitlab.research.corteva.com>"
|
||||||
|
)
|
||||||
|
assert headers["to"] == "Bendt <unknown>, Timothy <timothy.bendt@corteva.com>"
|
||||||
|
assert "Fabric3 Monorepo" in headers["subject"]
|
||||||
|
|
||||||
|
def test_parse_cancelled_meeting_headers(self, parser):
|
||||||
|
"""Test parsing headers from a cancelled meeting email."""
|
||||||
|
headers = parser(CANCELLED_MEETING_EMAIL)
|
||||||
|
|
||||||
|
# Should extract the first occurrence of headers (simplified format)
|
||||||
|
assert "Canceled: Technical Refinement" in headers.get("subject", "")
|
||||||
|
assert "corteva.com" in headers.get("to", "")
|
||||||
|
assert "cc" in headers # Should have CC
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripHeadersFromContent:
|
||||||
|
"""Tests for _strip_headers_from_content method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stripper(self):
|
||||||
|
"""Create a ContentContainer instance for testing."""
|
||||||
|
from src.mail.widgets.ContentContainer import ContentContainer
|
||||||
|
|
||||||
|
container = ContentContainer()
|
||||||
|
return container._strip_headers_from_content
|
||||||
|
|
||||||
|
def test_strip_simple_headers(self, stripper):
|
||||||
|
"""Test stripping simple headers from content."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
To: recipient@example.com
|
||||||
|
Subject: Test
|
||||||
|
|
||||||
|
This is the body.
|
||||||
|
"""
|
||||||
|
result = stripper(content)
|
||||||
|
|
||||||
|
assert "From:" not in result
|
||||||
|
assert "To:" not in result
|
||||||
|
assert "Subject:" not in result
|
||||||
|
assert "This is the body" in result
|
||||||
|
|
||||||
|
def test_strip_mime_boundaries(self, stripper):
|
||||||
|
"""Test stripping MIME boundary markers."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
Subject: Test
|
||||||
|
|
||||||
|
--boundary123456789
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
Hello world
|
||||||
|
|
||||||
|
--boundary123456789--
|
||||||
|
"""
|
||||||
|
result = stripper(content)
|
||||||
|
|
||||||
|
assert "--boundary" not in result
|
||||||
|
assert "Hello world" in result
|
||||||
|
|
||||||
|
def test_strip_base64_content(self, stripper):
|
||||||
|
"""Test stripping base64 encoded content."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
Subject: Test
|
||||||
|
|
||||||
|
--boundary
|
||||||
|
Content-Type: text/calendar
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
QkVHSU46VkNBTEVOREFSDQpNRVRIT0Q6Q0FOQ0VMDQpQUk9ESUQ6TWljcm9zb2Z0
|
||||||
|
IEV4Y2hhbmdlIFNlcnZlciAyMDEwDQpWRVJTSU9OOjIuMA0KQkVHSU46VlRJTUVa
|
||||||
|
|
||||||
|
--boundary--
|
||||||
|
"""
|
||||||
|
result = stripper(content)
|
||||||
|
|
||||||
|
# Should not contain base64 content
|
||||||
|
assert "QkVHSU46" not in result
|
||||||
|
assert "VKNTVU9OOjIuMA" not in result
|
||||||
|
|
||||||
|
def test_strip_cancelled_meeting_email(self, stripper):
|
||||||
|
"""Test stripping a cancelled meeting email - should result in empty/minimal content."""
|
||||||
|
result = stripper(CANCELLED_MEETING_EMAIL)
|
||||||
|
|
||||||
|
# Should not contain headers
|
||||||
|
assert "From:" not in result
|
||||||
|
assert "To:" not in result
|
||||||
|
assert "Subject:" not in result
|
||||||
|
assert "Received:" not in result
|
||||||
|
assert "Content-Type:" not in result
|
||||||
|
|
||||||
|
# Should not contain MIME boundaries
|
||||||
|
assert "--_002_" not in result
|
||||||
|
|
||||||
|
# Should not contain base64
|
||||||
|
assert "QkVHSU46" not in result
|
||||||
|
|
||||||
|
# The result should be empty or just whitespace since the text/plain part is empty
|
||||||
|
assert result.strip() == "" or len(result.strip()) < 50
|
||||||
|
|
||||||
|
def test_strip_vcalendar_content(self, stripper):
|
||||||
|
"""Test stripping vCalendar/ICS content."""
|
||||||
|
content = """From: sender@example.com
|
||||||
|
Subject: Meeting
|
||||||
|
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
METHOD:REQUEST
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:Team Meeting
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
"""
|
||||||
|
result = stripper(content)
|
||||||
|
|
||||||
|
assert "BEGIN:VCALENDAR" not in result
|
||||||
|
assert "END:VCALENDAR" not in result
|
||||||
|
assert "VEVENT" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatRecipients:
|
||||||
|
"""Tests for _format_recipients method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def formatter(self):
|
||||||
|
"""Create a ContentContainer instance for testing."""
|
||||||
|
from src.mail.widgets.ContentContainer import ContentContainer
|
||||||
|
|
||||||
|
container = ContentContainer()
|
||||||
|
return container._format_recipients
|
||||||
|
|
||||||
|
def test_format_string_recipient(self, formatter):
|
||||||
|
"""Test formatting a string recipient."""
|
||||||
|
result = formatter("user@example.com")
|
||||||
|
assert result == "user@example.com"
|
||||||
|
|
||||||
|
def test_format_dict_recipient(self, formatter):
|
||||||
|
"""Test formatting a dict recipient."""
|
||||||
|
result = formatter({"name": "John Doe", "addr": "john@example.com"})
|
||||||
|
assert result == "John Doe <john@example.com>"
|
||||||
|
|
||||||
|
def test_format_dict_recipient_name_only(self, formatter):
|
||||||
|
"""Test formatting a dict with name only."""
|
||||||
|
result = formatter({"name": "John Doe", "addr": ""})
|
||||||
|
assert result == "John Doe"
|
||||||
|
|
||||||
|
def test_format_dict_recipient_addr_only(self, formatter):
|
||||||
|
"""Test formatting a dict with addr only."""
|
||||||
|
result = formatter({"name": None, "addr": "john@example.com"})
|
||||||
|
assert result == "john@example.com"
|
||||||
|
|
||||||
|
def test_format_dict_recipient_empty(self, formatter):
|
||||||
|
"""Test formatting an empty dict recipient."""
|
||||||
|
result = formatter({"name": None, "addr": ""})
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_format_list_recipients(self, formatter):
|
||||||
|
"""Test formatting a list of recipients."""
|
||||||
|
result = formatter(
|
||||||
|
[
|
||||||
|
{"name": "John", "addr": "john@example.com"},
|
||||||
|
{"name": "Jane", "addr": "jane@example.com"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert result == "John <john@example.com>, Jane <jane@example.com>"
|
||||||
|
|
||||||
|
def test_format_empty(self, formatter):
|
||||||
|
"""Test formatting empty input."""
|
||||||
|
assert formatter(None) == ""
|
||||||
|
assert formatter("") == ""
|
||||||
|
assert formatter([]) == ""
|
||||||
Reference in New Issue
Block a user