diff --git a/src/mail/app.py b/src/mail/app.py index 0c86880..fe0f8a2 100644 --- a/src/mail/app.py +++ b/src/mail/app.py @@ -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.""" diff --git a/src/mail/email_viewer.tcss b/src/mail/email_viewer.tcss index 9d3ec9c..128e132 100644 --- a/src/mail/email_viewer.tcss +++ b/src/mail/email_viewer.tcss @@ -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 */ diff --git a/src/mail/widgets/ContentContainer.py b/src/mail/widgets/ContentContainer.py index 31097a2..ecb16a7 100644 --- a/src/mail/widgets/ContentContainer.py +++ b/src/mail/widgets/ContentContainer.py @@ -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.""" diff --git a/src/services/himalaya/client.py b/src/services/himalaya/client.py index 1f75baf..462817d 100644 --- a/src/services/himalaya/client.py +++ b/src/services/himalaya/client.py @@ -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}'" diff --git a/tests/test_header_parsing.py b/tests/test_header_parsing.py new file mode 100644 index 0000000..366d6c4 --- /dev/null +++ b/tests/test_header_parsing.py @@ -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 , Cody +To: Ruttencutter , Chris , Dake , Ryan , Smith , James , Santana , Jonatas +Cc: Bendt , Timothy +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" +To: "Ruttencutter, Chris" , "Dake, Ryan" + , "Smith, James" , + "Santana, Jonatas" +CC: "Bendt, Timothy" +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: + +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 +To: Jane Smith +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 " + assert headers["to"] == "Jane Smith " + 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 , + Second User , + Third User +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) +To: Bendt , Timothy +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) " + ) + assert "Timothy " in headers["to"] + assert "Bendt " 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) +To: Bendt , Timothy +Subject: Re: Fabric3 Monorepo | chore(deps): update vitest monorepo to v4 (major) (!6861) + +Renovate Bot (SA) pushed new commits to merge request !6861 + + * f96fec2b...2fb2ae10 - 2 commits from branch `main` +""" + headers = parser(content) + + assert ( + headers["from"] + == "Renovate Bot (SA @renovate-bot-sa) " + ) + assert headers["to"] == "Bendt , Timothy " + 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 " + + 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 , Jane " + + def test_format_empty(self, formatter): + """Test formatting empty input.""" + assert formatter(None) == "" + assert formatter("") == "" + assert formatter([]) == ""