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:
Bendt
2025-12-29 14:15:21 -05:00
parent de61795476
commit 09d4bc18d7
5 changed files with 667 additions and 17 deletions

View File

@@ -901,19 +901,19 @@ class EmailViewerApp(App):
def action_scroll_down(self) -> None:
"""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:
"""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:
"""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:
"""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:
"""Toggle between compressed and full envelope headers."""

View File

@@ -71,12 +71,23 @@ StatusTitle {
}
EnvelopeHeader {
dock: top;
width: 100%;
height: auto;
max-height: 10;
min-height: 4;
max-height: 6;
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 */

View File

@@ -112,8 +112,11 @@ def compress_urls_in_content(content: str, max_url_len: int = 50) -> str:
return "".join(result)
class EnvelopeHeader(Vertical):
"""Email envelope header with compressible To/CC fields."""
class EnvelopeHeader(ScrollableContainer):
"""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
MAX_RECIPIENTS_SHOWN = 2
@@ -134,7 +137,6 @@ class EnvelopeHeader(Vertical):
self._full_from = ""
def on_mount(self):
self.styles.height = "auto"
self.mount(self.subject_label)
self.mount(self.from_label)
self.mount(self.to_label)
@@ -142,6 +144,13 @@ class EnvelopeHeader(Vertical):
self.mount(self.date_label)
# Add bottom margin to subject for visual separation from metadata
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:
"""Compress a list of recipients to a single line with truncation.
@@ -276,8 +285,13 @@ class EnvelopeHeader(Vertical):
self._refresh_display()
class ContentContainer(ScrollableContainer):
"""Container for displaying email content with toggleable view modes."""
class ContentContainer(Vertical):
"""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
@@ -290,6 +304,7 @@ class ContentContainer(ScrollableContainer):
super().__init__(**kwargs)
self.md = MarkItDown()
self.header = EnvelopeHeader(id="envelope_header")
self.scroll_container = ScrollableContainer(id="content_scroll")
self.content = Markdown("", id="markdown_content")
self.html_content = Static("", id="html_content", markup=False)
self.current_content = None
@@ -314,8 +329,9 @@ class ContentContainer(ScrollableContainer):
def compose(self):
yield self.header
yield self.content
yield self.html_content
with self.scroll_container:
yield self.content
yield self.html_content
def on_mount(self):
# Set initial display based on config default
@@ -416,6 +432,36 @@ class ContentContainer(ScrollableContainer):
self.current_account = account
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
if self.current_mode == "markdown":
self.content.update("Loading...")
@@ -430,6 +476,158 @@ class ContentContainer(ScrollableContainer):
format_type = "text" if self.current_mode == "markdown" else "html"
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:
"""Clear the message content display."""
self.content.update("")
@@ -438,11 +636,75 @@ class ContentContainer(ScrollableContainer):
self.current_message_id = None
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:
"""Update the content widgets with the fetched content."""
if content is None:
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
self.current_content = content
@@ -507,13 +769,13 @@ class ContentContainer(ScrollableContainer):
return extract_links_from_content(self.current_content)
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
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.mount(self.calendar_panel, before=0)
self.scroll_container.mount(self.calendar_panel, before=0)
def _hide_calendar_panel(self) -> None:
"""Hide/remove the calendar invite panel."""

View File

@@ -286,6 +286,8 @@ async def get_message_content(
- Success status (True if operation was successful)
"""
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}"
if folder:
cmd += f" -f '{folder}'"

View 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([]) == ""