diff --git a/CHANGELOG.md b/CHANGELOG.md index 015598e..a0db01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] + +### Added +- Search history: pressing `Up`/`Down` in the search box (`S`) scrolls through previous queries (newest first). The recalled query is editable before pressing Enter. History is persisted across sessions to `~/.local/share/bor/search_history` (up to 200 entries, deduplicated). +- `Ctrl+R` in message view now toggles a rich extended-header block showing: Message-ID, In-Reply-To, References count, Priority, Size, Folder, Flags, Tags, and extra transport headers (Return-Path, Sender, X-Mailer/User-Agent, X-Spam-Status/Score, Authentication-Results, X-Originating-IP, first Received hop). Previously `Ctrl+R` was wired up but had no visible effect. +- Added `V` shortcut in message view to open the current message in the system browser as a full HTML preview (similar to mu4e's view-in-browser). Uses the HTML part if present, otherwise wraps plain text. A header block with From/To/Date/Subject is prepended. The message is written to a temporary file and opened via `webbrowser.open`. The temporary directory is configurable via `html.browser_tmp_dir` (defaults to the system temp dir). +- Implemented compose `Ctrl+I` insert functionality: opens the same Tab-completing file-path prompt used for attachments, then inserts the selected file's UTF-8 text content at the current editor cursor position. +- Added compose editor shortcuts: `Ctrl+T` now transposes characters around the cursor, and `Ctrl+Backspace` performs backward word deletion matching `Ctrl+W` behavior. + +### Fixed +- Fixed `MarkupError` crash when opening HTML emails whose converted plain text contains URLs with special characters (`"`, `]`, or trailing `)` from Markdown-style links). The URL is now sanitised before embedding it in a Rich `[link="…"]` markup tag, and a fallback renders the body without link markup if any error still occurs. +- Kitty inline image previews in the attachments tab now display at the image's natural pixel size instead of being stretched to fill the available terminal width. If the image is larger than the available preview area, it is scaled down to fit while preserving the aspect ratio. Cell pixel dimensions are queried via `TIOCGWINSZ`; a rough 2 px/cell fallback is used when the query fails. +- Fixed N/P (next/previous message) navigation breaking after opening attachments (Z) and returning (Q). The new `MessageViewWidget` received the fully-parsed message from `mu.view()` as its reference, whose `Message-ID` header retains angle brackets (``), while messages in the search index from `mu.find()` store msgids without them. The lookup always missed, so N/P silently did nothing. Fixed by stripping angle brackets in `mu.view()` when storing `msgid`, and normalising both sides of the comparison in the navigation actions. +- Fixed thread nesting depth computation (`_compute_thread_levels`). Previously, depth was calculated by counting the number of visible ancestors in the References list, which gave the wrong level whenever an email client (e.g. Outlook) only put the immediate parent in References rather than the full ancestor chain. Now each message's level is set to `parent_level + 1`, correctly nesting replies regardless of how short the References chain is. +- Fixed `Ctrl+T` (Show Thread) to find the complete thread even when reference chains are incomplete. The new search strategy uses `thread:` (mu's internal ThreadId field, equal to the oldest known reference) in addition to `msgid:`, and iterates until no new messages are found. This is particularly effective when multiple one-hop reference chains fragment the conversation into separate mu sub-threads. +- Fixed attachment extraction for messages signed with S/MIME (`multipart/signed`). The MIME part numbering now correctly counts `multipart/signed` (and `multipart/encrypted`) as extractable parts while still skipping structural multipart containers, matching mu's own numbering. Previously, PDFs in signed messages would extract the wrong part (a text/html body), saved with a generic name like `part-3` and opened in the browser instead of a PDF viewer. +- URLs in the message body are now clickable: clicking one opens it in the system browser. The `o` key still works for keyboard-driven URL selection. +- Reply now correctly handles mailing list emails. For messages with a `List-Post` header (e.g. Google Groups), replies go to the list address. For other messages with a `Reply-To` header, that address is used. The effective reply destination is shown in the message header as `List:` (for list mail) or `Reply-To:` (for non-list mail with a different reply address). + ## [0.4.0] ### Added diff --git a/bor/__init__.py b/bor/__init__.py index 733a50a..15e910c 100644 --- a/bor/__init__.py +++ b/bor/__init__.py @@ -5,5 +5,5 @@ and textual for the user interface. """ -__version__ = "0.4.0" +__version__ = "0.5.0" __author__ = "Bor Development Team" diff --git a/bor/config.py b/bor/config.py index e66512a..b7f0fd7 100644 --- a/bor/config.py +++ b/bor/config.py @@ -105,6 +105,7 @@ class HtmlConfig: """HTML rendering configuration.""" renderer: str = "html2text" open_links_in_browser: bool = True + browser_tmp_dir: str = "" @dataclass diff --git a/bor/mu.py b/bor/mu.py index fc79ce6..0fcb5c0 100644 --- a/bor/mu.py +++ b/bor/mu.py @@ -94,6 +94,7 @@ class EmailMessage: size: int = 0 from_addr: EmailAddress = field(default_factory=EmailAddress) reply_to_addr: Optional[EmailAddress] = None # Reply-To header if present + list_post_addr: Optional[EmailAddress] = None # List-Post header (mailing list address) to_addrs: List[EmailAddress] = field(default_factory=list) cc_addrs: List[EmailAddress] = field(default_factory=list) bcc_addrs: List[EmailAddress] = field(default_factory=list) @@ -106,6 +107,7 @@ class EmailMessage: body_html: str = "" attachments: List[Dict[str, Any]] = field(default_factory=list) thread_level: int = 0 # For threading display + extra_headers: Dict[str, str] = field(default_factory=dict) # Additional headers for full view @property def is_unread(self) -> bool: @@ -227,10 +229,25 @@ def get(key: str, default: Any = None) -> Any: msg.references = [str(r) for r in refs] msg.in_reply_to = get("in-reply-to", "") - # Parse Reply-To header - reply_to_data = get("reply-to", []) - if isinstance(reply_to_data, list) and reply_to_data: - msg.reply_to_addr = EmailAddress.from_mu(reply_to_data[0]) + # Parse Reply-To header (mu may return list, dict, or string) + reply_to_data = get("reply-to", None) + if reply_to_data: + if isinstance(reply_to_data, list) and reply_to_data: + msg.reply_to_addr = EmailAddress.from_mu(reply_to_data[0]) + elif not isinstance(reply_to_data, list): + msg.reply_to_addr = EmailAddress.from_mu(reply_to_data) + + # Parse List-Post header (mailing list address, takes priority over Reply-To for replies) + list_post_data = get("list-post", None) + if list_post_data: + if isinstance(list_post_data, list) and list_post_data: + addr = EmailAddress.from_mu(list_post_data[0]) + if addr.email: + msg.list_post_addr = addr + elif not isinstance(list_post_data, list): + addr = EmailAddress.from_mu(list_post_data) + if addr.email: + msg.list_post_addr = addr # Priority msg.priority = get("priority", "normal") @@ -405,29 +422,38 @@ def find( def _compute_thread_levels(self, messages: List[EmailMessage]) -> None: """ Compute thread_level for each message based on references. - - Only shows threading when the parent message is visible in the list. - Messages whose parents aren't visible are treated as root (level 0). + + Uses parent_level + 1 so that threads with incomplete reference chains + (e.g. Outlook only puts the immediate parent in References, not the full + ancestry) still nest correctly. Messages whose parent is not visible are + treated as roots (level 0). """ - # Build a set of message-ids in our result set - visible_msgids = set() - for msg in messages: + # Build msgid → position mapping (earlier index = earlier in thread order) + msgid_to_idx: Dict[str, int] = {} + for idx, msg in enumerate(messages): if msg.msgid: - visible_msgids.add(msg.msgid) - - for msg in messages: + msgid_to_idx[msg.msgid] = idx + + levels: List[int] = [0] * len(messages) + + for idx, msg in enumerate(messages): if not msg.references: - msg.thread_level = 0 - else: - # Count how many ancestors are visible in our list - # Walk the reference chain and count visible ones - visible_ancestors = 0 - for ref in msg.references: - if ref in visible_msgids: - visible_ancestors += 1 - - # Thread level is based on visible ancestors only - msg.thread_level = min(visible_ancestors, 10) # Cap at 10 + levels[idx] = 0 + continue + + # Walk references newest-first to find the closest visible ancestor + # (RFC 2822: last reference is the immediate parent) + parent_level = -1 + for ref in reversed(msg.references): + parent_idx = msgid_to_idx.get(ref) + if parent_idx is not None and parent_idx < idx: + parent_level = levels[parent_idx] + break + + levels[idx] = 0 if parent_level == -1 else parent_level + 1 + + for idx, msg in enumerate(messages): + msg.thread_level = min(levels[idx], 10) def find_by_msgid(self, msgid: str) -> Optional[EmailMessage]: """ @@ -455,14 +481,71 @@ def find_thread(self, message: EmailMessage) -> List[EmailMessage]: Returns: List of all messages in the thread """ + # Collect all message-IDs we know about for this thread: the message + # itself plus its full reference chain. + known_ids: set[str] = set() if message.msgid: - query = f"msgid:{message.msgid} OR refs:{message.msgid}" - else: + known_ids.add(message.msgid) + for ref in message.references or []: + if ref: + known_ids.add(ref) + + if not known_ids: # Fallback to subject-based threading subject = re.sub(r"^(re|fwd|fw):\s*", "", message.subject, flags=re.I) - query = f'subject:"{subject}"' + return self.find(f'subject:"{subject}"', threads=True, include_related=True) + + def _msgid_query(ids: set[str]) -> str: + return " OR ".join(f"msgid:{mid}" for mid in ids if mid) - return self.find(query, threads=True, include_related=True) + # Pass 1: search by msgid for all known IDs with --include-related. + # include-related uses mu's internal ThreadId field to expand to the + # full "sub-thread" for each found message. + pass1 = self.find(_msgid_query(known_ids), threads=True, include_related=True) + + # Expand with all msgids found in pass 1 (and their references). + expanded_ids: set[str] = set(known_ids) + for msg in pass1: + if msg.msgid: + expanded_ids.add(msg.msgid) + for ref in msg.references or []: + if ref: + expanded_ids.add(ref) + + if expanded_ids == known_ids: + return pass1 + + # Iteratively search using both msgid: and thread: until no new + # messages are found. mu's ThreadId = first_ref if refs else msgid, + # so thread:X finds messages whose first reference is X — exactly + # the descendants whose short reference chains (one hop only) would + # otherwise be missed by a pure msgid-based search. + MAX_PASSES = 5 + current_results = pass1 + current_ids = expanded_ids + + for _ in range(MAX_PASSES): + thread_parts = " OR ".join(f"thread:{mid}" for mid in current_ids if mid) + msgid_parts = _msgid_query(current_ids) + combined = f"({msgid_parts}) OR ({thread_parts})" + new_results = self.find(combined, threads=True, include_related=True) + + # Expand our known ID set with newly found messages + new_ids: set[str] = set(current_ids) + for msg in new_results: + if msg.msgid: + new_ids.add(msg.msgid) + for ref in msg.references or []: + if ref: + new_ids.add(ref) + + if new_ids == current_ids: + return new_results # Stable — no more to find + + current_results = new_results + current_ids = new_ids + + return current_results def view(self, path: str, mark_as_read: bool = True, msgid: str = "") -> Optional[EmailMessage]: """ @@ -496,7 +579,7 @@ def view(self, path: str, mark_as_read: bool = True, msgid: str = "") -> Optiona msg = EmailMessage() msg.path = path msg.subject = email_msg.get("Subject", "(no subject)") - msg.msgid = email_msg.get("Message-ID", "") + msg.msgid = email_msg.get("Message-ID", "").strip().strip("<>") # Parse From from_header = email_msg.get("From", "") @@ -524,10 +607,29 @@ def view(self, path: str, mark_as_read: bool = True, msgid: str = "") -> Optiona msg.bcc_addrs = [EmailAddress(name=name, email=email) for name, email in parsed_addrs if email] - # Parse Reply-To + # Parse Reply-To (use getaddresses for consistent multi-address handling) reply_to_header = email_msg.get("Reply-To", "") if reply_to_header: - msg.reply_to_addr = EmailAddress.from_mu(reply_to_header) + parsed_reply_to = email_utils.getaddresses([reply_to_header]) + if parsed_reply_to and parsed_reply_to[0][1]: + name, email = parsed_reply_to[0] + msg.reply_to_addr = EmailAddress(name=name, email=email) + + # Parse List-Post header — extract the mailto: address for mailing list replies + list_post_header = email_msg.get("List-Post", "") + if list_post_header: + mailto_match = re.search(r"]+)>", list_post_header, re.IGNORECASE) + if mailto_match: + msg.list_post_addr = EmailAddress(email=mailto_match.group(1)) + + # Parse In-Reply-To and References + in_reply_to_header = email_msg.get("In-Reply-To", "") + if in_reply_to_header: + msg.in_reply_to = str(in_reply_to_header).strip() + + references_header = email_msg.get("References", "") + if references_header: + msg.references = references_header.split() # Parse date date_header = email_msg.get("Date") @@ -544,15 +646,21 @@ def view(self, path: str, mark_as_read: bool = True, msgid: str = "") -> Optiona msg.attachments = [] if email_msg.is_multipart(): - # Track MIME part index for mu extract --parts command - # mu only counts leaf parts (non-multipart), not container parts + # Track MIME part index for mu extract --parts command. + # mu numbers parts in depth-first walk order starting at 1, but + # skips structural multipart containers (mixed, alternative, related…). + # Exception: multipart/signed and multipart/encrypted ARE counted + # because mu lists them as extractable parts. + _COUNTED_MULTIPART = {"multipart/signed", "multipart/encrypted"} part_index = 0 for part in email_msg.walk(): content_type = part.get_content_type() - # Skip multipart containers - mu doesn't count them - if content_type.startswith("multipart/"): + if content_type.startswith("multipart/") and content_type not in _COUNTED_MULTIPART: continue part_index += 1 + if content_type.startswith("multipart/"): + # Counted but not extractable as an attachment. + continue content_disposition = part.get("Content-Disposition", "") filename = part.get_filename() @@ -621,6 +729,26 @@ def view(self, path: str, mark_as_read: bool = True, msgid: str = "") -> Optiona else: msg.body_txt = text + # Capture extra headers for full-header display + _EXTRA_HEADER_NAMES = [ + "Return-Path", + "Sender", + "X-Mailer", + "User-Agent", + "X-Spam-Status", + "X-Spam-Score", + "Authentication-Results", + "X-Originating-IP", + ] + for hdr_name in _EXTRA_HEADER_NAMES: + val = email_msg.get(hdr_name, "") + if val: + msg.extra_headers[hdr_name] = str(val).strip() + # Include the first Received header (shows originating server/IP) + received = email_msg.get_all("Received") or [] + if received: + msg.extra_headers["Received"] = str(received[-1]).strip() + # Mark as read if requested and update path if it changed if mark_as_read and self._is_new_or_unread(path): new_path = self.mark_read(path) diff --git a/bor/tabs/attachments.py b/bor/tabs/attachments.py index a06099e..5974751 100644 --- a/bor/tabs/attachments.py +++ b/bor/tabs/attachments.py @@ -60,6 +60,53 @@ def compose(self) -> ComposeResult: yield Static(f"[{self.index}] {filename} ({content_type}, {size_str}){inline_marker}") +def _kitty_image_size_params(img_w: int, img_h: int, avail_cols: int, avail_rows: int) -> str: + """Return Kitty graphics size params string. + + Shows the image at its natural pixel size if it fits within the available + terminal cells; otherwise scales it down to fit, preserving aspect ratio. + Returns an empty string (no constraint) when the image fits naturally. + """ + if img_w <= 0 or img_h <= 0: + return f",c={avail_cols}" + + # Query terminal cell dimensions in pixels via TIOCGWINSZ. + cell_px_w = cell_px_h = 0.0 + try: + import fcntl + import struct + import termios + with open("/dev/tty") as _tty: + buf = struct.pack("HHHH", 0, 0, 0, 0) + res = struct.unpack("HHHH", fcntl.ioctl(_tty.fileno(), termios.TIOCGWINSZ, buf)) + t_rows, t_cols, t_px_w, t_px_h = res + if t_cols > 0 and t_rows > 0 and t_px_w > 0 and t_px_h > 0: + cell_px_w = t_px_w / t_cols + cell_px_h = t_px_h / t_rows + except Exception: + pass + + if cell_px_w > 0 and cell_px_h > 0: + # Image's natural footprint in terminal cells + natural_cols = img_w / cell_px_w + natural_rows = img_h / cell_px_h + if natural_cols <= avail_cols and natural_rows <= avail_rows: + # Fits at 1:1 pixel size — let Kitty auto-size + return "" + # Scale down to fit, preserving aspect ratio + scale = min(avail_cols / natural_cols, avail_rows / natural_rows) + fit_cols = max(1, int(natural_cols * scale)) + fit_rows = max(1, int(natural_rows * scale)) + return f",c={fit_cols},r={fit_rows}" + + # Fallback when pixel density is unknown: assume ~2 px tall per cell-width + scale_w = avail_cols / img_w + needed_rows = int(img_h * scale_w / 2) + if needed_rows <= avail_rows: + return f",c={avail_cols}" + return f",r={avail_rows}" + + class AttachmentPreview(ScrollableContainer): """Widget to preview attachment content.""" @@ -163,18 +210,20 @@ def _render_kitty_image(self) -> None: # Kitty graphics protocol only supports PNG natively (f=100) # For JPEG and other formats, we need to convert to PNG first # Check if it's PNG by magic bytes + img_width, img_height = 0, 0 if image_data[:4] != b'\x89PNG': # Try to convert to PNG using PIL if available try: from PIL import Image import io - + # Open image and convert to PNG img = Image.open(io.BytesIO(image_data)) + img_width, img_height = img.size # capture before conversion # Convert to RGBA if necessary (handles various formats) if img.mode not in ('RGB', 'RGBA'): img = img.convert('RGBA') - + # Save as PNG to bytes png_buffer = io.BytesIO() img.save(png_buffer, format='PNG') @@ -196,39 +245,23 @@ def _render_kitty_image(self) -> None: # Get image dimensions from PNG data (we converted to PNG above) # PNG dimensions are at bytes 16-24 (width: 16-20, height: 20-24) - img_width, img_height = 0, 0 - if image_data[:4] == b'\x89PNG' and len(image_data) >= 24: - img_width = int.from_bytes(image_data[16:20], 'big') - img_height = int.from_bytes(image_data[20:24], 'big') - + if img_width == 0 or img_height == 0: + if image_data[:4] == b'\x89PNG' and len(image_data) >= 24: + img_width = int.from_bytes(image_data[16:20], 'big') + img_height = int.from_bytes(image_data[20:24], 'big') + encoded = base64.standard_b64encode(image_data).decode("ascii") - + # Get the preview container's region (self is AttachmentPreview) - # Use the container size, not the content widget which just has text container_region = self.region - - # Calculate available space (in terminal cells) - # Leave margin for borders, padding, and title - avail_width = max(10, container_region.width - 4) - avail_height = max(5, container_region.height - 6) # -6 for border, padding, title - - # Determine which dimension to constrain to fit image in available space - # Terminal cells are roughly 2:1 (height:width in pixels), so 1 row ≈ 2 cols - # If image is W×H pixels, it would need W columns and H/2 rows (approx) - if img_width > 0 and img_height > 0: - # Calculate what rows we'd need if we use full available width - scale = avail_width / img_width - needed_rows = int((img_height * scale) / 2) # /2 for cell aspect ratio - - if needed_rows > avail_height: - # Image would be too tall, constrain by height instead - size_params = f",r={avail_height}" - else: - # Image fits, constrain by width - size_params = f",c={avail_width}" - else: - # Can't determine dimensions, just use width - size_params = f",c={avail_width}" + + # Available space in terminal cells (columns × rows) + avail_cols = max(10, container_region.width - 4) + avail_rows = max(5, container_region.height - 6) # border, padding, title + + # Determine size parameters: show at natural pixel size if it fits, + # otherwise scale down preserving aspect ratio. + size_params = _kitty_image_size_params(img_width, img_height, avail_cols, avail_rows) # Position cursor at the image location (inside the container) left = container_region.x + 2 diff --git a/bor/tabs/compose.py b/bor/tabs/compose.py index af811b5..f927768 100644 --- a/bor/tabs/compose.py +++ b/bor/tabs/compose.py @@ -435,6 +435,71 @@ def set_aliases(self, aliases: dict) -> None: """ self._aliases = aliases + @staticmethod + def _cursor_to_index(text: str, row: int, col: int) -> int: + """Convert (row, col) cursor location to absolute index.""" + lines = text.split("\n") + if not lines: + return 0 + + row = max(0, min(row, len(lines) - 1)) + col = max(0, min(col, len(lines[row]))) + + index = 0 + for line_index in range(row): + index += len(lines[line_index]) + 1 + index += col + return index + + @staticmethod + def _index_to_cursor(text: str, index: int) -> tuple[int, int]: + """Convert absolute index to (row, col) cursor location.""" + if not text: + return (0, 0) + + index = max(0, min(index, len(text))) + prefix = text[:index] + row = prefix.count("\n") + last_newline = prefix.rfind("\n") + col = index if last_newline == -1 else index - last_newline - 1 + return (row, col) + + @staticmethod + def _delete_previous_word(text: str, cursor_index: int) -> tuple[str, int]: + """Delete the previous word (and preceding whitespace) from cursor.""" + if cursor_index <= 0: + return text, 0 + + start = cursor_index + while start > 0 and text[start - 1].isspace(): + start -= 1 + while start > 0 and not text[start - 1].isspace(): + start -= 1 + + return text[:start] + text[cursor_index:], start + + @staticmethod + def _transpose_at_cursor(text: str, cursor_index: int) -> tuple[str, int]: + """Transpose characters around cursor position.""" + if len(text) < 2 or cursor_index <= 0: + return text, cursor_index + + if cursor_index >= len(text): + left_index = len(text) - 2 + right_index = len(text) - 1 + new_cursor = len(text) + else: + left_index = cursor_index - 1 + right_index = cursor_index + new_cursor = min(cursor_index + 1, len(text)) + + if text[left_index] == "\n" or text[right_index] == "\n": + return text, cursor_index + + chars = list(text) + chars[left_index], chars[right_index] = chars[right_index], chars[left_index] + return "".join(chars), new_cursor + def _on_key(self, event: events.Key) -> None: """Handle key events for text completion and commands.""" # Handle clipboard operations - must prevent default to avoid SIGINT @@ -468,6 +533,32 @@ def _on_key(self, event: events.Key) -> None: event.prevent_default() event.stop() return + + if event.key in {"ctrl+backspace", "ctrl+w"}: + row, col = self.cursor_location + cursor_index = self._cursor_to_index(self.text, row, col) + new_text, new_index = self._delete_previous_word(self.text, cursor_index) + + if new_text != self.text: + self.text = new_text + self.cursor_location = self._index_to_cursor(new_text, new_index) + + event.prevent_default() + event.stop() + return + + if event.key == "ctrl+t": + row, col = self.cursor_location + cursor_index = self._cursor_to_index(self.text, row, col) + new_text, new_index = self._transpose_at_cursor(self.text, cursor_index) + + if new_text != self.text: + self.text = new_text + self.cursor_location = self._index_to_cursor(new_text, new_index) + + event.prevent_default() + event.stop() + return # Check Ctrl+L sequences first if self.handle_ctrl_l_key(event): @@ -744,6 +835,7 @@ def __init__( self._email_aliases: dict = {} self._text_aliases: dict = {} self._last_attachment_dir: Path = Path.home() # Track last used directory + self._file_path_mode: Optional[str] = None self._draft_deleted: bool = False def compose(self) -> ComposeResult: @@ -774,7 +866,7 @@ def compose(self) -> ComposeResult: with Vertical(classes="attachment-input-bar hidden", id="attachment-input-bar"): yield Label("", id="attachment-completions", classes="attachment-completions") with Horizontal(): - yield Label("Attach file: ", classes="attachment-input-label") + yield Label("", id="file-path-input-label", classes="attachment-input-label") yield FilePathInput(id="attachment-path-input", classes="attachment-path-input") with Horizontal(classes="status-bar"): @@ -844,7 +936,10 @@ def _init_reply(self) -> None: to_input = self.query_one("#to-input", AddressInput) to_addrs = [] - if msg.reply_to_addr: + if msg.list_post_addr and msg.list_post_addr.email: + # Mailing list: reply to the list address (List-Post header) + to_addrs.append(str(msg.list_post_addr)) + elif msg.reply_to_addr and msg.reply_to_addr.email: to_addrs.append(str(msg.reply_to_addr)) else: to_addrs.append(str(msg.from_addr)) @@ -1167,6 +1262,12 @@ def _compose_references(reply_to: EmailMessage) -> str: return " ".join(chain) + @staticmethod + def _read_insert_file(path: Path) -> str: + """Read text content from a file for insertion into the editor.""" + with path.open("r", encoding="utf-8") as handle: + return handle.read() + def _build_message(self) -> MIMEMultipart: """ Build the email message for sending. @@ -1384,8 +1485,9 @@ def on_attach_file(self, event: AttachFile) -> None: self.action_attach_file() def on_file_path_input_submitted(self, event: FilePathInput.Submitted) -> None: - """Handle file path submitted for attachment.""" - path = Path(event.path) + """Handle file path submitted for attachment or insertion.""" + path = Path(event.path).expanduser() + mode = self._file_path_mode or "attach" # Hide the input bar self.query_one("#attachment-input-bar", Vertical).add_class("hidden") @@ -1400,25 +1502,44 @@ def on_file_path_input_submitted(self, event: FilePathInput.Submitted) -> None: self.notify(f"Not a file: {path}", severity="error") self.query_one("#body-input").focus() return - - # Add to attachments list - if path not in self.attachments: - self.attachments.append(path) - self._update_attachment_bar() - self.notify(f"Attached: {path.name}") + + body_input = self.query_one("#body-input", ComposeTextArea) + + if mode == "insert": + try: + content = self._read_insert_file(path) + except UnicodeDecodeError: + self.notify(f"File is not valid UTF-8 text: {path.name}", severity="error") + body_input.focus() + return + except OSError as error: + self.notify(f"Error reading file: {error}", severity="error") + body_input.focus() + return + + body_input.focus() + body_input.insert(content) + self.notify(f"Inserted: {path.name}") else: - self.notify(f"Already attached: {path.name}", severity="warning") + if path not in self.attachments: + self.attachments.append(path) + self._update_attachment_bar() + self.notify(f"Attached: {path.name}") + else: + self.notify(f"Already attached: {path.name}", severity="warning") # Remember the directory for next time self._last_attachment_dir = path.parent + self._file_path_mode = None # Return focus to body - self.query_one("#body-input").focus() + body_input.focus() def on_file_path_input_cancelled(self, event: FilePathInput.Cancelled) -> None: """Handle file path input cancelled.""" # Hide the input bar self.query_one("#attachment-input-bar", Vertical).add_class("hidden") + self._file_path_mode = None # Return focus to body self.query_one("#body-input").focus() @@ -1453,14 +1574,19 @@ def action_search(self) -> None: def action_insert_file(self) -> None: """Insert file contents into body.""" - # In a full implementation, this would open a file picker - # For now, just show a notification - self.notify("File insertion not yet implemented") + self._show_file_path_input(mode="insert", label="Insert file: ") def action_attach_file(self) -> None: """Attach a file.""" + self._show_file_path_input(mode="attach", label="Attach file: ") + + def _show_file_path_input(self, mode: str, label: str) -> None: + """Show the path input UI used for attach/insert actions.""" + self._file_path_mode = mode + # Show the attachment input bar self.query_one("#attachment-input-bar", Vertical).remove_class("hidden") + self.query_one("#file-path-input-label", Label).update(label) # Clear completions display initially self.query_one("#attachment-completions", Label).update("") diff --git a/bor/tabs/message.py b/bor/tabs/message.py index 709b350..c872e59 100644 --- a/bor/tabs/message.py +++ b/bor/tabs/message.py @@ -6,11 +6,16 @@ from __future__ import annotations +import hashlib +import html import re -import subprocess +import tempfile import webbrowser +from pathlib import Path from typing import Callable, Optional +from rich.markup import escape as rich_escape + from textual import events from textual.app import ComposeResult from textual.binding import Binding @@ -23,6 +28,26 @@ from bor.config import get_config +_URL_RE = re.compile(r'(https?://\S+)') + + +def _make_body_markup(content: str) -> str: + """Escape Rich markup in plain-text body and wrap URLs in clickable link tags.""" + parts = _URL_RE.split(content) + result = [] + for i, part in enumerate(parts): + if i % 2 == 1: # URL + # Strip common trailing punctuation plus chars that close Markdown/HTML constructs + url = part.rstrip('.,;:!?)]>') + trailing = part[len(url):] + # Escape chars that would break Rich's [link="url"] attribute syntax + safe_url = url.replace('"', '%22').replace(']', '%5D') + result.append(f'[link="{safe_url}"]{rich_escape(url)}[/link]{rich_escape(trailing)}') + else: + result.append(rich_escape(part)) + return "".join(result) + + def html_to_text(html: str) -> str: """ Convert HTML to plain text. @@ -114,42 +139,86 @@ def _format_headers(self) -> str: lines = [] # From - lines.append(f"[bold]From:[/bold] {self.message.from_addr}") + lines.append(f"[bold]From:[/bold] {rich_escape(str(self.message.from_addr))}") + + # Show effective reply address when it differs from From + if self.message.list_post_addr and self.message.list_post_addr.email: + # Mailing list: show the list address (this is where 'r' will send) + if self.message.list_post_addr.email != self.message.from_addr.email: + lines.append(f"[bold]List:[/bold] {rich_escape(self.message.list_post_addr.email)}") + elif (self.message.reply_to_addr and + self.message.reply_to_addr.email != self.message.from_addr.email): + lines.append(f"[bold]Reply-To:[/bold] {rich_escape(str(self.message.reply_to_addr))}") # To - to_list = ", ".join(str(addr) for addr in self.message.to_addrs) + to_list = rich_escape(", ".join(str(addr) for addr in self.message.to_addrs)) lines.append(f"[bold]To:[/bold] {to_list}") # CC if self.message.cc_addrs: - cc_list = ", ".join(str(addr) for addr in self.message.cc_addrs) + cc_list = rich_escape(", ".join(str(addr) for addr in self.message.cc_addrs)) lines.append(f"[bold]CC:[/bold] {cc_list}") # BCC (only in full header mode) if self.show_full and self.message.bcc_addrs: - bcc_list = ", ".join(str(addr) for addr in self.message.bcc_addrs) + bcc_list = rich_escape(", ".join(str(addr) for addr in self.message.bcc_addrs)) lines.append(f"[bold]BCC:[/bold] {bcc_list}") # Date date_str = "" if self.message.date: date_str = self.message.date.strftime("%Y-%m-%d %H:%M:%S %Z") - lines.append(f"[bold]Date:[/bold] {date_str}") + lines.append(f"[bold]Date:[/bold] {rich_escape(date_str)}") # Subject - lines.append(f"[bold]Subject:[/bold] {self.message.subject}") + lines.append(f"[bold]Subject:[/bold] {rich_escape(self.message.subject)}") # Attachments count if self.message.attachments: count = len(self.message.attachments) lines.append(f"[bold]Attach:[/bold] {count} attachment(s)") - # Full headers + # Full / rich headers if self.show_full: + lines.append("") # visual separator + if self.message.msgid: - lines.append(f"[bold]Msg-ID:[/bold] {self.message.msgid}") + lines.append(f"[bold]Message-ID:[/bold] {rich_escape(self.message.msgid)}") if self.message.in_reply_to: - lines.append(f"[bold]Reply-To:[/bold] {self.message.in_reply_to}") + lines.append(f"[bold]In-Reply-To:[/bold] {rich_escape(self.message.in_reply_to)}") + if self.message.references: + n = len(self.message.references) + sample = " ".join(self.message.references[-2:]) + suffix = f" (… {n} total)" if n > 2 else "" + lines.append(f"[bold]References:[/bold] {rich_escape(sample)}{suffix}") + + if self.message.priority and self.message.priority != "normal": + lines.append(f"[bold]Priority:[/bold] {rich_escape(self.message.priority)}") + + if self.message.size: + size = self.message.size + if size >= 1024 * 1024: + size_str = f"{size / (1024 * 1024):.1f} MB" + elif size >= 1024: + size_str = f"{size / 1024:.1f} KB" + else: + size_str = f"{size} B" + lines.append(f"[bold]Size:[/bold] {size_str}") + + if self.message.maildir: + lines.append(f"[bold]Folder:[/bold] {rich_escape(self.message.maildir)}") + + if self.message.flags: + lines.append(f"[bold]Flags:[/bold] {rich_escape(', '.join(self.message.flags))}") + + if self.message.tags: + lines.append(f"[bold]Tags:[/bold] {rich_escape(', '.join(self.message.tags))}") + + # Extra headers captured from the raw file + for hdr_name, val in self.message.extra_headers.items(): + # Truncate very long values (e.g. Authentication-Results) + display_val = val if len(val) <= 100 else val[:97] + "…" + lines.append(f"[bold]{rich_escape(hdr_name)}:[/bold] {rich_escape(display_val)}") return "\n".join(lines) @@ -298,6 +367,7 @@ class MessageViewWidget(BaseTab): Binding("c", "compose", "Compose"), Binding("z", "attachments", "Attachments"), Binding("o", "open_url", "Open URL"), + Binding("v", "view_in_browser", "View in Browser"), Binding("ctrl+r", "toggle_full_headers", "Full Headers"), ] @@ -345,7 +415,7 @@ def compose(self) -> ComposeResult: with ScrollableContainer(): yield MessageHeader(self._message_ref, id="msg-header") yield Static("", id="attachment-info", classes="attachment-info") - yield Static("Loading...", id="msg-body", markup=False) + yield Static("Loading...", id="msg-body") yield ConfirmBar("", id="confirm-bar") yield FlagBar("", id="flag-bar") yield ReplyBar("", id="reply-bar") @@ -408,7 +478,10 @@ def _load_message(self) -> None: # Update body body = self.query_one("#msg-body", Static) - body.update(self._content) + try: + body.update(_make_body_markup(self._content)) + except Exception: + body.update(rich_escape(self._content)) # Update tab title title = self._full_message.subject[:20] + "..." if len(self._full_message.subject) > 20 else self._full_message.subject @@ -485,17 +558,17 @@ def action_next_message(self) -> None: Navigation follows the index order (threaded or date-sorted). """ # Find current message's position in the index by msgid - current_msgid = self._message_ref.msgid + current_msgid = self._message_ref.msgid.strip().strip("<>") current_idx = None for idx, msg in enumerate(self.bor_app._current_messages): - if msg.msgid == current_msgid: + if msg.msgid.strip().strip("<>") == current_msgid: current_idx = idx break - + # If current message is not in index, do nothing if current_idx is None: return - + # Check if there's a next message if current_idx + 1 < len(self.bor_app._current_messages): self.bor_app._current_index = current_idx + 1 @@ -510,17 +583,17 @@ def action_prev_message(self) -> None: Navigation follows the index order (threaded or date-sorted). """ # Find current message's position in the index by msgid - current_msgid = self._message_ref.msgid + current_msgid = self._message_ref.msgid.strip().strip("<>") current_idx = None for idx, msg in enumerate(self.bor_app._current_messages): - if msg.msgid == current_msgid: + if msg.msgid.strip().strip("<>") == current_msgid: current_idx = idx break - + # If current message is not in index, do nothing if current_idx is None: return - + # Check if there's a previous message if current_idx > 0: self.bor_app._current_index = current_idx - 1 @@ -716,7 +789,7 @@ def action_toggle_full_headers(self) -> None: if self._full_message: header = self.query_one("#msg-header", MessageHeader) header.show_full = self.show_full_headers - header.refresh() + header.update_message(self._full_message) def on_click(self, event: events.Click) -> None: """Handle clicks on links.""" @@ -782,3 +855,73 @@ def _open_url(self, url: str) -> None: webbrowser.open(url) except Exception: pass + + def action_view_in_browser(self) -> None: + """Open the current message as HTML in the system browser.""" + if not self._full_message: + self.notify("No message loaded") + return + + msg = self._full_message + + if msg.body_html: + body_html = msg.body_html + # If the HTML doesn't have a full document structure, wrap it + if "{body_html}" + else: + # Wrap plain text in a minimal HTML page + plain = msg.body_txt or "(No message content)" + escaped = html.escape(plain) + body_html = ( + "" + f"
{escaped}
" + "" + ) + + # Build header block to prepend + from_str = html.escape(str(msg.from_addr)) + to_str = html.escape(", ".join(str(a) for a in msg.to_addrs)) + date_str = html.escape(msg.date.strftime("%Y-%m-%d %H:%M:%S %Z") if msg.date else "") + subject_str = html.escape(msg.subject) + + header_html = ( + "
" + f"From: {from_str}
" + f"To: {to_str}
" + f"Date: {date_str}
" + f"Subject: {subject_str}" + "
" + ) + + # Inject header just after (or prepend if not found) + lower = body_html.lower() + body_tag_end = lower.find("", body_tag_end) + 1 + full_html = body_html[:body_tag_end] + header_html + body_html[body_tag_end:] + else: + full_html = header_html + body_html + + try: + config = get_config() + tmp_dir_str = config.html.browser_tmp_dir + tmp_dir = ( + Path(tmp_dir_str).expanduser() + if tmp_dir_str + else Path(tempfile.gettempdir()) + ) + tmp_dir.mkdir(parents=True, exist_ok=True) + + # Use a deterministic filename based on the message identifier so + # that re-opening the same message reuses the file instead of + # accumulating many bor_msg_*.html files. + key = msg.msgid or (str(msg.docid) if msg.docid else full_html) + file_hash = hashlib.sha256(key.encode()).hexdigest()[:16] + tmp_path = tmp_dir / f"bor_msg_{file_hash}.html" + tmp_path.write_text(full_html, encoding="utf-8") + webbrowser.open(tmp_path.as_uri()) + self.notify("Message opened in browser") + except Exception as e: + self.notify(f"Could not open browser: {e}") diff --git a/bor/tabs/message_index.py b/bor/tabs/message_index.py index d1ea19b..177fb51 100644 --- a/bor/tabs/message_index.py +++ b/bor/tabs/message_index.py @@ -7,6 +7,7 @@ from __future__ import annotations from datetime import datetime +from pathlib import Path from typing import Callable, List, Optional, Set from rich.style import Style as RichStyle @@ -23,29 +24,107 @@ from bor.config import get_config +_HISTORY_FILE = Path.home() / ".local" / "share" / "bor" / "search_history" +_MAX_HISTORY = 200 + + class SearchInput(Input): - """Input widget for search.""" + """Input widget for search with Up/Down history navigation.""" def __init__(self, *args, **kwargs) -> None: """Initialize search input.""" super().__init__(*args, placeholder="Search...", **kwargs) + self._history: list[str] = [] + self._history_pos: int = -1 # -1 = not browsing; ≥0 = index into _history + self._saved_value: str = "" # typed value saved when history browsing starts + self._load_history() + + # ------------------------------------------------------------------ + # History persistence + + def _load_history(self) -> None: + """Load search history from disk.""" + try: + if _HISTORY_FILE.exists(): + lines = _HISTORY_FILE.read_text(encoding="utf-8").splitlines() + self._history = [l for l in lines if l.strip()] + except Exception: + pass + + def _save_to_history(self, query: str) -> None: + """Append a query to history (deduplicating) and persist to disk.""" + query = query.strip() + if not query: + return + try: + self._history.remove(query) + except ValueError: + pass + self._history.append(query) + if len(self._history) > _MAX_HISTORY: + self._history = self._history[-_MAX_HISTORY:] + self._history_pos = -1 + try: + _HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) + _HISTORY_FILE.write_text("\n".join(self._history), encoding="utf-8") + except Exception: + pass + + # ------------------------------------------------------------------ + # Public API + + def reset_for_new_search(self) -> None: + """Clear the input and reset history browsing position.""" + self.value = "" + self._history_pos = -1 + self._saved_value = "" + + # ------------------------------------------------------------------ + # Key handling def on_key(self, event: events.Key) -> None: """Handle key events.""" if event.key == "escape": - # Hide search bar search_bar = self.parent search_bar.remove_class("visible") self.display = False - # Focus the DataTable - need to go up to screen level to find it + self._history_pos = -1 try: self.screen.query_one(DataTable).focus() except Exception: pass event.prevent_default() event.stop() + + elif event.key == "up": + if self._history: + if self._history_pos == -1: + # Begin browsing: save whatever the user had typed + self._saved_value = self.value + self._history_pos = len(self._history) - 1 + elif self._history_pos > 0: + self._history_pos -= 1 + # at oldest entry (pos == 0) further Up is a no-op + self.value = self._history[self._history_pos] + self.cursor_position = len(self.value) + event.prevent_default() + event.stop() + + elif event.key == "down": + if self._history_pos != -1: + if self._history_pos < len(self._history) - 1: + self._history_pos += 1 + self.value = self._history[self._history_pos] + else: + # Past the newest entry: restore the originally typed value + self._history_pos = -1 + self.value = self._saved_value + self.cursor_position = len(self.value) + event.prevent_default() + event.stop() + elif event.key == "enter": - # Trigger search + self._save_to_history(self.value) self.post_message(SearchInput.Submitted(self.value)) event.prevent_default() event.stop() @@ -692,7 +771,7 @@ def action_mu_search(self) -> None: search_bar.add_class("visible") search_input = self.query_one("#search-input", SearchInput) search_input.display = True - search_input.value = "" + search_input.reset_for_new_search() search_input.focus() async def action_show_inbox(self) -> None: diff --git a/documentation/configuration.md b/documentation/configuration.md index 2e3fcea..aad398a 100644 --- a/documentation/configuration.md +++ b/documentation/configuration.md @@ -166,6 +166,11 @@ renderer = "html2text" # Open links in browser when clicked open_links_in_browser = true + +# Directory for temporary HTML files created by "view in browser" (V key). +# Defaults to the system temp directory when unset. +# Useful when the system temp dir (/tmp) is restricted by browser security policies. +# browser_tmp_dir = "~/tmp/bor" ``` ### [attachments] @@ -255,6 +260,11 @@ flag_unread = "●" flag_replied = "↩" flag_attachment = "📎" +[html] +renderer = "html2text" +open_links_in_browser = true +# browser_tmp_dir = "~/tmp/bor" + [aliases] r = "Best regards,\nJohn" t = "Thanks!" diff --git a/documentation/keyboard_shortcuts.md b/documentation/keyboard_shortcuts.md index 9a4f9d2..0710396 100644 --- a/documentation/keyboard_shortcuts.md +++ b/documentation/keyboard_shortcuts.md @@ -110,6 +110,7 @@ Complete reference of all keyboard shortcuts in Bor email reader. | A | Apply flag (U/N/F to add, Shift+U/N/F to remove) | | D | Delete message (with y/n confirmation) | | O | Open URL (if multiple, pick [1-9]) | +| V | View message in browser (full HTML preview) | | Z | View attachments | | Ctrl+R | Toggle full headers | diff --git a/pyproject.toml b/pyproject.toml index 004fbdf..26024cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bormail" -version = "0.4.0" +version = "0.5.0" description = "Terminal-based email reader using mu and textual" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_app_integration.py b/tests/test_app_integration.py index 9265bc0..d7a7724 100644 --- a/tests/test_app_integration.py +++ b/tests/test_app_integration.py @@ -33,6 +33,8 @@ def __init__(self, **kwargs): self.body_html = kwargs.get("body_html", "") self.attachments = kwargs.get("attachments", []) self.thread_level = kwargs.get("thread_level", 0) + self.reply_to_addr = kwargs.get("reply_to_addr", None) + self.list_post_addr = kwargs.get("list_post_addr", None) @property def is_unread(self): diff --git a/tests/test_compose.py b/tests/test_compose.py index 6a2c4af..251d854 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -1,7 +1,9 @@ from pathlib import Path +import pytest + from bor.mu import EmailMessage, EmailAddress -from bor.tabs.compose import ComposeWidget, AddressInput +from bor.tabs.compose import ComposeWidget, AddressInput, ComposeTextArea import email.utils @@ -35,43 +37,41 @@ def test_compose_references_deduplicates_ids() -> None: def test_smtp_address_extraction_with_commas() -> None: """Test that addresses with commas in display names are extracted correctly for SMTP.""" # Simulate what would be in the To/CC/BCC headers after formatting - to_header = '"Slosar, Anze" , "Derose, Joseph" ' + to_header = '"Smith, Alice" , "Brown, Bob" ' cc_header = 'John Doe ' - + # Extract addresses the way _send_message does to_addrs = [] to_addrs.extend([email for name, email in email.utils.getaddresses([to_header])]) to_addrs.extend([email for name, email in email.utils.getaddresses([cc_header])]) to_addrs = [addr for addr in to_addrs if addr] - + # Should extract exactly 3 email addresses assert len(to_addrs) == 3 - assert 'slosar@gmail.com' in to_addrs - assert 'derose@bnl.gov' in to_addrs + assert 'alice@example.com' in to_addrs + assert 'bob@example.org' in to_addrs assert 'john@example.com' in to_addrs def test_find_address_start_with_quoted_commas() -> None: """Test that _find_address_start correctly handles commas in quoted names.""" addr_input = AddressInput() - + # Test 1: Single address with comma in quoted name - text1 = '"Slosar, Anze" ' + text1 = '"Smith, Alice" ' start1 = addr_input._find_address_start(text1, len(text1)) assert start1 == 0, "Should start at beginning for single quoted address" - + # Test 2: After separator comma (not quoted comma) - text2 = '"Slosar, Anze" , John' + text2 = '"Smith, Alice" , John' start2 = addr_input._find_address_start(text2, len(text2)) - assert start2 == 34, "Should find position after separator comma" - assert text2[start2:].strip() == "John" - + assert text2[start2:].strip() == "John", "Should find position after separator comma" + # Test 3: Second address with quoted comma - text3 = '"Derose, Joseph" , "Slosar, Anze" ' + text3 = '"Brown, Bob" , "Smith, Alice" ' start3 = addr_input._find_address_start(text3, len(text3)) - assert start3 == 34, "Should find start of second quoted address" - assert text3[start3:].strip() == '"Slosar, Anze" ' - + assert text3[start3:].strip() == '"Smith, Alice" ' + # Test 4: Escaped quotes in name text4 = r'"Joe \"The Boss\" Smith" , Another' start4 = addr_input._find_address_start(text4, len(text4)) @@ -199,6 +199,79 @@ def test_reply_all_excludes_self_from_cc() -> None: assert all("charlie@example.com" not in entry for entry in cc_list) +def test_reply_uses_list_post_for_mailing_list_emails() -> None: + """Test that replying to a mailing list email uses List-Post, not Reply-To (individual). + + Models the case where a mailing list hides sender addresses: + From = list address (sender privacy on) + Reply-To = individual sender + List-Post = list address + The reply should go to the list, not the individual. + """ + original_msg = EmailMessage( + msgid="", + from_addr=EmailAddress(name="'sender@example.com' via Test List", email="list@example.org"), + to_addrs=[EmailAddress(name="Test List", email="list@example.org")], + reply_to_addr=EmailAddress(name="Sender", email="sender@example.com"), + list_post_addr=EmailAddress(email="list@example.org"), + ) + + # Simulate what _init_reply does to pick the To address + to_addrs = [] + if original_msg.list_post_addr and original_msg.list_post_addr.email: + to_addrs.append(str(original_msg.list_post_addr)) + elif original_msg.reply_to_addr and original_msg.reply_to_addr.email: + to_addrs.append(str(original_msg.reply_to_addr)) + else: + to_addrs.append(str(original_msg.from_addr)) + + # Reply should go to the list, not the individual sender + assert len(to_addrs) == 1 + assert "list@example.org" in to_addrs[0] + assert "sender@example.com" not in to_addrs[0] + + +def test_reply_uses_reply_to_header_when_no_list_post() -> None: + """Test that replying to a non-list message with Reply-To uses that address.""" + original_msg = EmailMessage( + msgid="", + from_addr=EmailAddress(name="Alice", email="alice@example.com"), + to_addrs=[EmailAddress(name="Me", email="me@example.com")], + reply_to_addr=EmailAddress(name="Alice Support", email="support@example.com"), + list_post_addr=None, + ) + + to_addrs = [] + if original_msg.list_post_addr and original_msg.list_post_addr.email: + to_addrs.append(str(original_msg.list_post_addr)) + elif original_msg.reply_to_addr and original_msg.reply_to_addr.email: + to_addrs.append(str(original_msg.reply_to_addr)) + else: + to_addrs.append(str(original_msg.from_addr)) + + assert len(to_addrs) == 1 + assert "support@example.com" in to_addrs[0] + + +def test_reply_falls_back_to_from_without_reply_to() -> None: + """Test that replying without Reply-To header uses From address.""" + original_msg = EmailMessage( + msgid="", + from_addr=EmailAddress(name="Alice", email="alice@example.com"), + to_addrs=[EmailAddress(name="Bob", email="bob@example.com")], + reply_to_addr=None, + ) + + to_addrs = [] + if original_msg.reply_to_addr and original_msg.reply_to_addr.email: + to_addrs.append(str(original_msg.reply_to_addr)) + else: + to_addrs.append(str(original_msg.from_addr)) + + assert len(to_addrs) == 1 + assert "alice@example.com" in to_addrs[0] + + def test_forward_attachments_keep_duplicate_names(tmp_path: Path) -> None: """Forwarding should keep all attachments with duplicate filenames.""" attachments = [ @@ -223,4 +296,59 @@ def fake_extract(message_path: str, part_index: int, target_dir: str) -> str: names = sorted(path.name for path in result) assert names == ["file (2).txt", "file.txt"] assert (tmp_path / "file.txt").read_text() == "part 1" - assert (tmp_path / "file (2).txt").read_text() == "part 2" \ No newline at end of file + assert (tmp_path / "file (2).txt").read_text() == "part 2" + + +def test_read_insert_file_returns_contents(tmp_path: Path) -> None: + """Insert helper should read UTF-8 text files fully.""" + source = tmp_path / "snippet.txt" + source.write_text("first line\nsecond line", encoding="utf-8") + + assert ComposeWidget._read_insert_file(source) == "first line\nsecond line" + + +def test_read_insert_file_rejects_non_utf8(tmp_path: Path) -> None: + """Insert helper should raise for non-UTF-8 content.""" + source = tmp_path / "binary.bin" + source.write_bytes(b"\xff\xfe\x00\x00") + + with pytest.raises(UnicodeDecodeError): + ComposeWidget._read_insert_file(source) + + +def test_delete_previous_word_removes_word_and_whitespace() -> None: + """Backward delete should remove previous word and separator spacing.""" + source = "hello world" + text, index = ComposeTextArea._delete_previous_word(source, len(source)) + assert text == "hello " + assert index == 8 + + +def test_delete_previous_word_across_newline() -> None: + """Backward delete should work across line boundaries.""" + source = "hello\nworld" + text, index = ComposeTextArea._delete_previous_word(source, len(source)) + assert text == "hello\n" + assert index == 6 + + +def test_transpose_at_cursor_middle() -> None: + """Transpose should swap char before cursor with char at cursor.""" + text, index = ComposeTextArea._transpose_at_cursor("abcd", 2) + assert text == "acbd" + assert index == 3 + + +def test_transpose_at_cursor_end_of_text() -> None: + """Transpose at end should swap final two characters.""" + text, index = ComposeTextArea._transpose_at_cursor("abcd", 4) + assert text == "abdc" + assert index == 4 + + +def test_transpose_does_not_cross_newline() -> None: + """Transpose should no-op when it would involve a newline character.""" + source = "ab\ncd" + text, index = ComposeTextArea._transpose_at_cursor(source, 2) + assert text == source + assert index == 2 \ No newline at end of file diff --git a/tests/test_mu.py b/tests/test_mu.py index a83b352..9e89352 100644 --- a/tests/test_mu.py +++ b/tests/test_mu.py @@ -35,10 +35,10 @@ def test_name_and_email(self): def test_name_with_comma(self): """Test email address with comma in name (should be quoted).""" - addr = EmailAddress(name="Derose, Joseph", email="derose@bnl.gov") - assert addr.name == "Derose, Joseph" - assert addr.email == "derose@bnl.gov" - assert str(addr) == '"Derose, Joseph" ' + addr = EmailAddress(name="Smith, Jane", email="jsmith@example.com") + assert addr.name == "Smith, Jane" + assert addr.email == "jsmith@example.com" + assert str(addr) == '"Smith, Jane" ' def test_name_with_quotes(self): """Test email address with quotes in name (should be escaped).""" @@ -53,19 +53,19 @@ def test_name_with_multiple_special_chars(self): def test_comma_in_name_parses_correctly(self): """Test that comma in name parses correctly with email.utils.""" import email.utils - + # Create address with comma in name - addr = EmailAddress(name="Derose, Joseph", email="derose@bnl.gov") + addr = EmailAddress(name="Smith, Jane", email="jsmith@example.com") addr_str = str(addr) - + # Parse it back using email.utils.getaddresses parsed = email.utils.getaddresses([addr_str]) - + # Should parse as single address with correct name and email assert len(parsed) == 1 name, email = parsed[0] - assert name == "Derose, Joseph" - assert email == "derose@bnl.gov" + assert name == "Smith, Jane" + assert email == "jsmith@example.com" def test_from_mu_string(self): """Test parsing from string.""" @@ -76,11 +76,11 @@ def test_from_mu_string(self): def test_from_mu_quoted_string(self): """Test parsing from string with quoted name (from mu output).""" # Mu may provide names with quotes if they contain special chars - addr = EmailAddress.from_mu('"Bolton, Adam" ') - assert addr.name == "Bolton, Adam" - assert addr.email == "abolton@slac.stanford.edu" + addr = EmailAddress.from_mu('"Brown, Adam" ') + assert addr.name == "Brown, Adam" + assert addr.email == "abrown@example.org" # When converted back to string, should have single layer of quotes - assert str(addr) == '"Bolton, Adam" ' + assert str(addr) == '"Brown, Adam" ' def test_from_mu_escaped_quotes(self): """Test parsing from string with escaped quotes in name.""" @@ -109,44 +109,45 @@ def test_from_mu_none(self): def test_header_parsing_with_comma_in_name(self): """Test that email headers with commas in names parse correctly. - + Regression test for bug where splitting on comma would break - quoted names like "Slosar, Anze" into two malformed addresses. + quoted names like "Smith, Alice" into two + malformed addresses. """ import email.utils as email_utils - + # Simulate what happens when reading a To: header from an email file - to_header = '"Slosar, Anze" ' - + to_header = '"Smith, Alice" ' + # Parse using email.utils.getaddresses (the correct way) parsed_addrs = email_utils.getaddresses([to_header]) - to_addrs = [EmailAddress(name=name, email=email) - for name, email in parsed_addrs if email] - + to_addrs = [EmailAddress(name=name, email=email) + for name, email in parsed_addrs if email] + # Should result in exactly one address with correct name assert len(to_addrs) == 1 - assert to_addrs[0].name == "Slosar, Anze" - assert to_addrs[0].email == "anze@bnl.gov" - + assert to_addrs[0].name == "Smith, Alice" + assert to_addrs[0].email == "alice@example.com" + # Display should be properly formatted - assert str(to_addrs[0]) == '"Slosar, Anze" ' - + assert str(to_addrs[0]) == '"Smith, Alice" ' + def test_header_parsing_multiple_addresses_with_commas(self): """Test parsing multiple addresses where some have commas in names.""" import email.utils as email_utils - - to_header = '"Slosar, Anze" , "O\'Connor, Paul" , simple@example.com' - + + to_header = '"Smith, Alice" , "O\'Brien, Paul" , simple@example.com' + parsed_addrs = email_utils.getaddresses([to_header]) - to_addrs = [EmailAddress(name=name, email=email) - for name, email in parsed_addrs if email] - + to_addrs = [EmailAddress(name=name, email=email) + for name, email in parsed_addrs if email] + # Should result in exactly three addresses assert len(to_addrs) == 3 - assert to_addrs[0].name == "Slosar, Anze" - assert to_addrs[0].email == "anze@bnl.gov" - assert to_addrs[1].name == "O'Connor, Paul" - assert to_addrs[1].email == "poc@bnl.gov" + assert to_addrs[0].name == "Smith, Alice" + assert to_addrs[0].email == "alice@example.com" + assert to_addrs[1].name == "O'Brien, Paul" + assert to_addrs[1].email == "paul@example.org" assert to_addrs[2].name == "" assert to_addrs[2].email == "simple@example.com" @@ -210,6 +211,128 @@ def test_from_mu_json(self): assert msg.has_attachments assert msg.priority == "high" + def test_from_mu_json_with_reply_to_list(self): + """Test that Reply-To is parsed from mu JSON when present as a list.""" + data = { + "from": [{"name": "Sender", "email": "sender@example.com"}], + "to": [{"name": "Recipient", "email": "recipient@example.com"}], + ":reply-to": [{"name": "Test List", "email": "list@example.org"}], + } + msg = EmailMessage.from_mu_json(data) + assert msg.reply_to_addr is not None + assert msg.reply_to_addr.email == "list@example.org" + + def test_from_mu_json_with_reply_to_dict(self): + """Test that Reply-To is parsed when mu returns it as a dict (not list).""" + data = { + "from": [{"name": "Sender", "email": "sender@example.com"}], + ":reply-to": {"name": "Test List", "email": "list@example.org"}, + } + msg = EmailMessage.from_mu_json(data) + assert msg.reply_to_addr is not None + assert msg.reply_to_addr.email == "list@example.org" + + def test_from_mu_json_no_reply_to(self): + """Test that reply_to_addr is None when Reply-To is absent.""" + data = { + "from": [{"name": "Sender", "email": "sender@example.com"}], + } + msg = EmailMessage.from_mu_json(data) + assert msg.reply_to_addr is None + + def test_from_mu_json_with_list_post(self): + """Test that List-Post is parsed from mu JSON (mailing list emails). + + Models a mailing list where the sender's address is hidden: From shows + the list address, Reply-To has the individual sender, List-Post has the + list address. + """ + data = { + "from": [{":name": "'sender@example.com' via Test List", ":email": "list@example.org"}], + ":reply-to": [{":email": "sender@example.com", ":name": "sender@example.com"}], + ":list-post": [{":email": "list@example.org"}], + } + msg = EmailMessage.from_mu_json(data) + assert msg.list_post_addr is not None + assert msg.list_post_addr.email == "list@example.org" + # reply_to_addr points to the individual sender + assert msg.reply_to_addr is not None + assert msg.reply_to_addr.email == "sender@example.com" + + def test_view_reply_to_parsing(self): + """Test that view() correctly parses Reply-To header from raw email.""" + import tempfile + from unittest.mock import patch + + raw = ( + b"From: Alice Example \r\n" + b"To: me@example.com\r\n" + b"Subject: Test\r\n" + b"Reply-To: Test List \r\n" + b"Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n" + b"\r\n" + b"Test body\r\n" + ) + + with tempfile.NamedTemporaryFile(suffix=".eml", delete=False, mode="wb") as f: + f.write(raw) + tmp_path = f.name + + try: + mu = MuInterface() + with patch.object(mu, "_run_mu", return_value=None): + msg = mu.view(tmp_path, mark_as_read=False) + + assert msg is not None + assert msg.from_addr.email == "alice@example.com" + assert msg.reply_to_addr is not None + assert msg.reply_to_addr.email == "list@example.org" + assert msg.reply_to_addr.name == "Test List" + finally: + Path(tmp_path).unlink(missing_ok=True) + + def test_view_list_post_parsing(self): + """Test view() handles mailing list format: From=list, Reply-To=individual, List-Post=list. + + This models the case where a mailing list hides the sender's address: + From has the list address, Reply-To has the individual, List-Post has the list. + Replies should go to the list (via List-Post), not the individual. + """ + import tempfile + from unittest.mock import patch + + raw = ( + b"From: \"'sender@example.com' via Test List\" \r\n" + b"To: Test List \r\n" + b"Subject: Some discussion topic\r\n" + b"Reply-To: \"sender@example.com\" \r\n" + b"List-Post: , \r\n" + b"Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n" + b"\r\n" + b"Test body\r\n" + ) + + with tempfile.NamedTemporaryFile(suffix=".eml", delete=False, mode="wb") as f: + f.write(raw) + tmp_path = f.name + + try: + mu = MuInterface() + with patch.object(mu, "_run_mu", return_value=None): + msg = mu.view(tmp_path, mark_as_read=False) + + assert msg is not None + # From has list address in angle brackets + assert msg.from_addr.email == "list@example.org" + # Reply-To points to individual sender + assert msg.reply_to_addr is not None + assert msg.reply_to_addr.email == "sender@example.com" + # List-Post points to the list + assert msg.list_post_addr is not None + assert msg.list_post_addr.email == "list@example.org" + finally: + Path(tmp_path).unlink(missing_ok=True) + class TestMuInterface: """Tests for MuInterface class."""