diff --git a/scripts/odysseus-mail b/scripts/odysseus-mail index e63d9f166..443541df9 100755 --- a/scripts/odysseus-mail +++ b/scripts/odysseus-mail @@ -43,7 +43,7 @@ import logging import os import re import sys -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path # Anchor the path so the script works when invoked from anywhere. @@ -136,7 +136,7 @@ def cmd_list(args) -> None: emit([], args) return all_uids = data[0].split() - # Newest first, limit + # Keep a bounded arrival batch; order its decoded rows by Date below. uids = list(reversed(all_uids))[: args.limit] out = [] for uid in uids: @@ -165,10 +165,15 @@ def cmd_list(args) -> None: sender_name, sender_addr = parseaddr(sender) date_raw = msg.get("Date", "") try: - iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else "" + parsed_date = parsedate_to_datetime(date_raw) if date_raw else None + iso = parsed_date.isoformat() if parsed_date else "" + if parsed_date is not None and parsed_date.tzinfo is None: + parsed_date = parsed_date.replace(tzinfo=timezone.utc) + date_epoch = parsed_date.timestamp() if parsed_date else float("-inf") except Exception: iso = "" - out.append({ + date_epoch = float("-inf") + out.append((date_epoch, { "uid": uid.decode(), "date": iso, "from_addr": sender_addr, @@ -176,10 +181,11 @@ def cmd_list(args) -> None: "subject": subject, "is_read": "\\Seen" in flags, "is_answered": "\\Answered" in flags, - }) + })) except Exception as e: sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n") - emit(out, args) + out.sort(key=lambda item: item[0], reverse=True) + emit([row for _, row in out], args) # ─── read ──────────────────────────────────────────────────────────── diff --git a/tests/cli/test_mail_cli_date_order.py b/tests/cli/test_mail_cli_date_order.py new file mode 100644 index 000000000..ff07ce21b --- /dev/null +++ b/tests/cli/test_mail_cli_date_order.py @@ -0,0 +1,66 @@ +"""The bounded arrival batch is displayed by Date, rather than arrival ID.""" +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest +from tests.cli.test_mail_cli_read_empty_fetch import _load_mail_cli + + +@pytest.mark.parametrize('dates,expected', [ + (['Wed, 03 Jun 2026 12:00:00 +0000', 'Mon, 01 Jun 2026 09:00:00 +0000'], ['1', '2']), + (['Wed, 03 Jun 2026 12:00:00 +0000', 'Wed, 03 Jun 2026 13:00:00 +0200'], ['1', '2']), + (['Wed, 03 Jun 2026 12:00:00', 'Wed, 03 Jun 2026 11:00:00 +0000'], ['1', '2']), + (['Wed, 03 Jun 2026 12:00:00 +0000', None, 'not a date'], ['1', '3', '2']), + (['Wed, 03 Jun 2026 12:00:00 +0000', 'Wed, 03 Jun 2026 14:00:00 +0200'], ['2', '1']), + (['Wed, 03 Jun 2026 12:00:00 +0000', 'Thu, 04 Jun 2026 12:00:00 +0000'], ['2', '1']), + (['Wed, 31 Dec 1969 12:00:00 +0000', None], ['1', '2']), +]) +def test_list_sorts_parsed_dates_without_changing_row_schema(monkeypatch, dates, expected): + cli = _load_mail_cli(monkeypatch) + fetched = [] + + class Connection: + def select(self, folder, readonly=True): + assert readonly is True + return 'OK', [str(len(dates)).encode()] + + def search(self, *_): + return 'OK', [' '.join(str(i+1) for i in range(len(dates))).encode()] + + def fetch(self, uid, _): + fetched.append(uid.decode()) + date = dates[int(uid)-1] + raw = 'From: Sender \r\nSubject: Item '+uid.decode()+'\r\n' + if date is not None: + raw += 'Date: '+date+'\r\n' + return 'OK', [(b'1 (FLAGS (\\Seen) RFC822.HEADER)', (raw+'\r\n').encode())] + + @contextmanager + def imap(_): + yield Connection() + + rows = [] + monkeypatch.setattr(cli, '_imap', imap) + monkeypatch.setattr(cli, 'emit', lambda value, args: rows.extend(value)) + cli.cmd_list(SimpleNamespace(account='fixture', folder='INBOX', limit=20)) + assert [row['uid'] for row in rows] == expected + assert fetched == [str(i) for i in range(len(dates), 0, -1)] + assert all(set(row) == {'uid', 'date', 'from_addr', 'from_name', 'subject', 'is_read', 'is_answered'} for row in rows) + assert all(row['is_read'] and not row['is_answered'] for row in rows) + + +def test_list_still_limits_the_arrival_batch(monkeypatch): + cli = _load_mail_cli(monkeypatch) + fetched = [] + class Connection: + def select(self, *_args, **_kwargs):return 'OK', [] + def search(self, *_):return 'OK', [b'1 2 3'] + def fetch(self, uid, _): + fetched.append(uid) + return 'OK', [(b'3 (FLAGS () RFC822.HEADER)', b'From: x@example.com\r\nSubject: bounded\r\n\r\n')] + @contextmanager + def imap(_):yield Connection() + monkeypatch.setattr(cli, '_imap', imap) + monkeypatch.setattr(cli, 'emit', lambda *_:None) + cli.cmd_list(SimpleNamespace(account='fixture', folder='INBOX', limit=1)) + assert fetched == [b'3']