diff --git a/NEWS b/NEWS index 438148913..94c2fbb6b 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,9 @@ +unreleased: +* feature: new config setting "msg_summary_show_subject" to display each + message's Subject on its summary line in thread mode. The 'different' + mode shows the subject of a reply only when it diverges from its parent + message; (issue #566). + 0.12: * breaking: drop support for python 3.8, add support for python 3.13 & 3.14 * deps: Increase required version of notmuch2 to 0.34.2 diff --git a/alot/db/message.py b/alot/db/message.py index 6b1a3d9b8..61f37479e 100644 --- a/alot/db/message.py +++ b/alot/db/message.py @@ -69,6 +69,11 @@ def __init__(self, dbman, msg, thread=None): else: self._from = '"Unknown" <>' + try: + self._subject = decode_header(msg.header('Subject')) + except (NullPointerError, LookupError): + self._subject = '' + def __str__(self): """prettyprint the message""" aname, aaddress = self.get_author() @@ -174,6 +179,15 @@ def get_author(self): """ return email.utils.parseaddr(self._from) + def get_subject(self): + """ + returns the decoded :mailheader:`Subject` header for this message, + captured at construction time. Empty string if absent or unreadable. + + :rtype: str + """ + return self._subject + def add_tags(self, tags, afterwards=None, remove_rest=False): """ adds tags to message diff --git a/alot/defaults/alot.rc.spec b/alot/defaults/alot.rc.spec index 8cc85c072..5e75e88f8 100644 --- a/alot/defaults/alot.rc.spec +++ b/alot/defaults/alot.rc.spec @@ -292,6 +292,18 @@ envelope_html2txt = string(default=None) # messages in that thread. msg_summary_hides_threadwide_tags = boolean(default=True) +# In a thread buffer, show each message's Subject on its summary line. +# Valid values are: +# +# * 'never' (the default), do not show subjects in the summary +# * 'always', always show the subject +# * 'different', show the subject of each reply only when it differs from +# its parent message's subject after normalisation (localised reply/forward +# prefixes such as 'Re:', 'Fwd:', 'Aw:'... and ``[list-name]`` mailing-list +# tags are stripped before comparison). Root messages always show their +# subject in this mode, to provide thread context when the thread is folded. +msg_summary_show_subject = option('never', 'always', 'different', default='never') + # The list of headers to match to determine sending account for a reply. # Headers are searched in the order in which they are specified here, and the first header # containing a match is used. If multiple accounts match in that header, the one defined diff --git a/alot/defaults/default.theme b/alot/defaults/default.theme index 5bc259972..58fa85a2f 100644 --- a/alot/defaults/default.theme +++ b/alot/defaults/default.theme @@ -43,6 +43,8 @@ even = 'default','','white','light blue','white','#006' odd = 'default','','white','dark blue','white','#068' focus = 'standout','','white','light gray','#ff8','g58' + subject_even = 'default','','light gray','light blue','light gray','#006' + subject_odd = 'default','','light gray','dark blue','light gray','#068' [envelope] body = 'default','','light gray','default','light gray','default' diff --git a/alot/defaults/theme.spec b/alot/defaults/theme.spec index 387bbe2a1..34598cc01 100644 --- a/alot/defaults/theme.spec +++ b/alot/defaults/theme.spec @@ -64,6 +64,8 @@ even = attrtriple odd = attrtriple focus = attrtriple + subject_even = attrtriple(default=None) + subject_odd = attrtriple(default=None) [envelope] body = attrtriple header = attrtriple diff --git a/alot/helper.py b/alot/helper.py index 1c7a3552d..449a88f13 100644 --- a/alot/helper.py +++ b/alot/helper.py @@ -585,6 +585,34 @@ def get_xdg_env(env_name, fallback): return env if env else fallback +_SUBJECT_PREFIX_RE = re.compile( + r'^\s*(?:' + r'(?:re|aw|wg|fwd|fw|tr|sv|vs|odp|rv|res|rif)' + r'\s*(?:\[\d+\])?\s*:' + r'|' + r'\[[^\]]+\]' + r')\s*', + re.IGNORECASE, +) + + +def normalize_subject(subject): + """Strip leading reply/forward prefixes and bracketed list tags from a + subject line. + + Recognises common localised prefixes (en/de/fr/it/es/pl/no/da/fi/nl) plus + the ``Re[2]:`` counter form, and ``[list-name]`` style mailing-list tags. + Stripping is recursive, case-insensitive and whitespace-tolerant, so + ``"Re: [list] Re: Hello"`` and ``"[list] Re: Hello"`` both reduce to + ``"Hello"``. This matches the way notmuch normalises thread subjects. + """ + prev = None + while subject and subject != prev: + prev = subject + subject = _SUBJECT_PREFIX_RE.sub('', subject).strip() + return subject + + def get_notmuch_config_path(): """ Find the notmuch config file via env vars and default locations """ # This code is modeled after the description in nomtuch-config(1) diff --git a/alot/widgets/thread.py b/alot/widgets/thread.py index c5276df83..30d671dff 100644 --- a/alot/widgets/thread.py +++ b/alot/widgets/thread.py @@ -16,7 +16,7 @@ from ..settings.const import settings from ..db.attachment import Attachment from ..db.utils import decode_header, X_SIGNATURE_MESSAGE_HEADER -from ..helper import string_sanitize +from ..helper import string_sanitize, normalize_subject ANSI_BACKGROUND = settings.get("interpret_ansi_background") @@ -26,12 +26,17 @@ class MessageSummaryWidget(urwid.WidgetWrap): one line summary of a :class:`~alot.db.message.Message`. """ - def __init__(self, message, even=True): + def __init__(self, message, even=True, parent_message=None): """ :param message: a message :type message: alot.db.Message :param even: even entry in a pile of messages? Used for theming. :type even: bool + :param parent_message: the message this is a reply to, or ``None`` if + this is a root of the thread. Used by the ``'different'`` + mode of ``msg_summary_show_subject`` to decide whether to show + the subject; root messages always show their subject. + :type parent_message: alot.db.Message or None """ self.message = message self.even = even @@ -47,6 +52,26 @@ def __init__(self, message, even=True): txt = urwid.Text(sumstr) cols.append(txt) + subject_mode = settings.get('msg_summary_show_subject') + if subject_mode != 'never': + msg_subject = message.get_subject() or '' + show_subject = False + if subject_mode == 'always': + show_subject = bool(msg_subject) + elif subject_mode == 'different': + if parent_message is None: + show_subject = bool(msg_subject) + else: + parent_subject = parent_message.get_subject() or '' + show_subject = (normalize_subject(msg_subject) + != normalize_subject(parent_subject)) + if show_subject: + subj_attr = settings.get_theming_attribute( + 'thread', 'summary', + 'subject_even' if even else 'subject_odd') + cols.append(urwid.Text((subj_attr, msg_subject), + wrap='ellipsis')) + if settings.get('msg_summary_hides_threadwide_tags'): thread_tags = message.get_thread().get_tags(intersection=True) outstanding_tags = set(message.get_tags()).difference(thread_tags) @@ -158,15 +183,21 @@ class MessageTree(CollapsibleTree): Collapsing this message corresponds to showing the summary only. """ - def __init__(self, message, odd=True): + def __init__(self, message, odd=True, parent_message=None): """ :param message: Message to display :type message: alot.db.Message :param odd: theme summary widget as if this is an odd line (in the message-pile) :type odd: bool + :param parent_message: the message ``message`` is a reply to, or + ``None`` if this is a thread root. Forwarded to + :class:`MessageSummaryWidget` so 'different' mode can compare + the subject against the actual parent. + :type parent_message: alot.db.Message or None """ self._message = message + self._parent_message = parent_message self._odd = odd self.display_source = False self._summaryw = None @@ -252,7 +283,8 @@ def collapse_if_matches(self, querystring): def _get_summary(self): if self._summaryw is None: self._summaryw = MessageSummaryWidget( - self._message, even=(not self._odd)) + self._message, even=(not self._odd), + parent_message=self._parent_message) return self._summaryw def _get_source(self): @@ -382,10 +414,10 @@ def __init__(self, thread): self._prev_sibling_of = {} self._message = {} - def accumulate(msg, odd=True): + def accumulate(msg, odd=True, parent=None): """recursively read msg and its replies""" mid = msg.get_message_id() - self._message[mid] = MessageTree(msg, odd) + self._message[mid] = MessageTree(msg, odd, parent_message=parent) odd = not odd last = None self._first_child_of[mid] = None @@ -397,7 +429,7 @@ def accumulate(msg, odd=True): self._prev_sibling_of[rid] = last self._next_sibling_of[last] = rid last = rid - odd = accumulate(reply, odd) + odd = accumulate(reply, odd, parent=msg) self._last_child_of[mid] = last return odd diff --git a/docs/source/configuration/alotrc_table b/docs/source/configuration/alotrc_table index adb848e1f..cf0128b8e 100644 --- a/docs/source/configuration/alotrc_table +++ b/docs/source/configuration/alotrc_table @@ -439,6 +439,25 @@ :default: True +.. _msg-summary-show-subject: + +.. describe:: msg_summary_show_subject + + In a thread buffer, show each message's Subject on its summary line. + Valid values are: + + * 'never' (the default), do not show subjects in the summary + * 'always', always show the subject + * 'different', show the subject of each reply only when it differs from + its parent message's subject after normalisation (localised reply/forward + prefixes such as 'Re:', 'Fwd:', 'Aw:'... and ``[list-name]`` mailing-list + tags are stripped before comparison). Root messages always show their + subject in this mode, to provide thread context when the thread is folded. + + :type: option, one of ['never', 'always', 'different'] + :default: never + + .. _namedqueries-statusbar: .. describe:: namedqueries_statusbar diff --git a/tests/db/test_message.py b/tests/db/test_message.py index 3735ec311..8bb58eb8d 100644 --- a/tests/db/test_message.py +++ b/tests/db/test_message.py @@ -120,3 +120,23 @@ def test_get_author_no_name(self): mock.Mock(return_value=[acc])): msg = message.Message(mock.Mock(), MockNotmuchMessage()) self.assertEqual(msg.get_author(), ('Unknown', '')) + + def test_get_subject_missing(self): + """Message.get_subject() returns the empty string when the Subject + header is absent.""" + msg = message.Message(mock.Mock(), MockNotmuchMessage()) + self.assertEqual(msg.get_subject(), '') + + def test_get_subject_plain(self): + """Message.get_subject() returns the Subject header verbatim when + no encoding is involved.""" + msg = message.Message( + mock.Mock(), MockNotmuchMessage({'Subject': 'Hello world'})) + self.assertEqual(msg.get_subject(), 'Hello world') + + def test_get_subject_rfc2047(self): + """Message.get_subject() decodes RFC 2047 encoded-words.""" + msg = message.Message( + mock.Mock(), + MockNotmuchMessage({'Subject': '=?utf-8?Q?H=C3=A9?='})) + self.assertEqual(msg.get_subject(), 'Hé') diff --git a/tests/settings/test_manager.py b/tests/settings/test_manager.py index 6548ee82a..82d4a5438 100644 --- a/tests/settings/test_manager.py +++ b/tests/settings/test_manager.py @@ -83,6 +83,33 @@ def test_read_config_doesnt_exist(self): manager.get_theming_attribute('global', 'body') + def test_thread_summary_subject_even_odd_theming_present(self): + """The default theme must define 'subject_even' and 'subject_odd' + attributes under thread.summary so the widget can colour the + subject column with a background matching the alternating + even/odd row backgrounds.""" + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write('') + self.addCleanup(os.unlink, f.name) + manager = SettingsManager() + manager.read_config(f.name) + even_attr = manager.get_theming_attribute( + 'thread', 'summary', 'subject_even') + odd_attr = manager.get_theming_attribute( + 'thread', 'summary', 'subject_odd') + self.assertIsNotNone(even_attr) + self.assertIsNotNone(odd_attr) + + def test_msg_summary_show_subject_default_never(self): + """The msg_summary_show_subject option defaults to 'never' so the + thread view stays unchanged for users who don't opt in.""" + with tempfile.NamedTemporaryFile(mode='w+', delete=False) as f: + f.write('') + self.addCleanup(os.unlink, f.name) + manager = SettingsManager() + manager.read_config(f.name) + self.assertEqual(manager.get('msg_summary_show_subject'), 'never') + def test_unknown_settings_in_config_are_logged(self): # todo: For py3, don't mock the logger, use assertLogs unknown_settings = ['templates_dir', 'unknown_section', 'unknown_1', diff --git a/tests/test_helper.py b/tests/test_helper.py index 2a3cb7335..250244334 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -481,3 +481,57 @@ def test_parsing_working(self): 'Subject': ['Re: Hello'], 'In-reply-to': ['']}, '') self.assertEqual(actual, expected) + + +class TestNormalizeSubject(unittest.TestCase): + + def test_empty(self): + self.assertEqual(helper.normalize_subject(''), '') + + def test_no_prefix(self): + self.assertEqual(helper.normalize_subject('Hello'), 'Hello') + + def test_re_prefix(self): + self.assertEqual(helper.normalize_subject('Re: Hello'), 'Hello') + + def test_case_insensitive(self): + self.assertEqual(helper.normalize_subject('RE: Hello'), 'Hello') + self.assertEqual(helper.normalize_subject('rE: Hello'), 'Hello') + + def test_recursive(self): + self.assertEqual( + helper.normalize_subject('Re: Re: Re: Hello'), 'Hello') + + def test_mixed_recursive(self): + self.assertEqual( + helper.normalize_subject('Fwd: Re: Aw: Hello'), 'Hello') + + def test_localized_prefixes(self): + for prefix in ('Fwd', 'Fw', 'Aw', 'Wg', 'Tr', 'Sv', 'Vs', + 'Odp', 'Rv', 'Res', 'Rif'): + with self.subTest(prefix=prefix): + self.assertEqual( + helper.normalize_subject(f'{prefix}: Hello'), 'Hello') + + def test_strips_leading_list_tag(self): + self.assertEqual( + helper.normalize_subject('[list-name] Hello'), 'Hello') + + def test_strips_re_then_list(self): + self.assertEqual( + helper.normalize_subject('Re: [list-name] Hello'), 'Hello') + + def test_strips_list_then_re(self): + self.assertEqual( + helper.normalize_subject('[list-name] Re: Hello'), 'Hello') + + def test_strips_multiple_list_tags(self): + self.assertEqual( + helper.normalize_subject('[list] [other] Hello'), 'Hello') + + def test_counter_form(self): + self.assertEqual(helper.normalize_subject('Re[2]: Hello'), 'Hello') + + def test_strips_whitespace(self): + self.assertEqual(helper.normalize_subject(' Re: Hello '), + 'Hello') diff --git a/tests/widgets/test_thread.py b/tests/widgets/test_thread.py new file mode 100644 index 000000000..e57805f85 --- /dev/null +++ b/tests/widgets/test_thread.py @@ -0,0 +1,234 @@ +# Copyright © 2026 alot contributors +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +import unittest +from unittest import mock + +import urwid + + +def _make_summary_widget(subject_mode='never', msg_subject='', + thread_subject='', parent_subject=None, + msg_tags=None, thread_intersection_tags=None): + """Construct a MessageSummaryWidget with mocked settings & message. + + ``parent_subject=None`` means the message is at the root of the thread + tree (no parent). Otherwise a mock parent message is built and passed + to the widget so 'different' mode can compare against it. + """ + msg = mock.Mock() + msg.get_author.return_value = ('Alice', 'alice@example.com') + msg.get_datestring.return_value = '2026-01-01' + msg.get_subject.return_value = msg_subject + msg.get_tags.return_value = msg_tags or [] + msg.get_thread.return_value.get_tags.return_value = ( + thread_intersection_tags or set()) + msg.get_thread.return_value.get_subject.return_value = thread_subject + + parent = None + if parent_subject is not None: + parent = mock.Mock() + parent.get_subject.return_value = parent_subject + + settings_get = { + 'msg_summary_hides_threadwide_tags': True, + 'msg_summary_show_subject': subject_mode, + } + + attr = ('default', '', 'default', 'default', 'default', 'default') + + def fake_tag_repr(tag, *args, **kwargs): + return {'translated': tag, 'normal': attr, 'focussed': attr} + + with mock.patch('alot.widgets.thread.settings') as s, \ + mock.patch('alot.widgets.globals.settings') as g: + s.get.side_effect = lambda k: settings_get[k] + s.get_theming_attribute.return_value = attr + g.get_tagstring_representation.side_effect = fake_tag_repr + from alot.widgets.thread import MessageSummaryWidget + return MessageSummaryWidget(msg, parent_message=parent) + + +def _column_widgets(widget): + """Return the bare widgets inside the urwid.Columns of a summary.""" + columns = widget._w.original_widget + return [w for w, _ in columns.contents] + + +def _column_texts(widget): + """Return only the urwid.Text widgets' contents inside a summary.""" + return [w.text for w in _column_widgets(widget) + if isinstance(w, urwid.Text)] + + +class TestMessageSummaryWidget(unittest.TestCase): + + def test_never_mode_does_not_add_subject_column(self): + """In 'never' mode (the default) the summary line keeps its existing + single text column (auteur (date)) — backward compatibility.""" + widget = _make_summary_widget( + subject_mode='never', msg_subject='Hello world') + texts = _column_texts(widget) + self.assertEqual(texts, ['Alice (2026-01-01)']) + + def test_always_mode_adds_subject_column(self): + """In 'always' mode the message subject is rendered as a separate + text column on the summary line.""" + widget = _make_summary_widget( + subject_mode='always', msg_subject='Hello world') + texts = _column_texts(widget) + self.assertIn('Hello world', texts) + + def test_different_mode_root_always_shows(self): + """In 'different' mode, a root message (no parent) always shows + its subject so the user has thread context when the thread is + folded — even when the subject equals the thread subject.""" + widget = _make_summary_widget( + subject_mode='different', + msg_subject='Hello', + thread_subject='Hello', + parent_subject=None) + texts = _column_texts(widget) + self.assertIn('Hello', texts) + + def test_different_mode_hides_when_same_as_parent(self): + """In 'different' mode, a reply whose subject normalises to the + same value as its parent's subject is hidden.""" + widget = _make_summary_widget( + subject_mode='different', + msg_subject='Re: Hello', + parent_subject='Hello') + texts = _column_texts(widget) + self.assertNotIn('Re: Hello', texts) + self.assertEqual(texts, ['Alice (2026-01-01)']) + + def test_different_mode_shows_when_diverges_from_parent(self): + """In 'different' mode, a reply whose normalised subject differs + from its parent's normalised subject is shown.""" + widget = _make_summary_widget( + subject_mode='different', + msg_subject='Re: Goodbye', + parent_subject='Hello') + texts = _column_texts(widget) + self.assertIn('Re: Goodbye', texts) + + def test_thread_tree_propagates_parent_to_replies(self): + """ThreadTree must pass each message as the parent of its replies' + MessageTree, so that 'different' mode can compare a reply's + subject to its actual parent's subject (issue #566 follow-up).""" + def fake_msg(mid): + m = mock.Mock() + m.get_message_id.return_value = mid + m.get_author.return_value = ('A', 'a@b') + m.get_datestring.return_value = '' + m.get_subject.return_value = '' + m.get_tags.return_value = [] + m.get_thread.return_value.get_tags.return_value = set() + m.get_thread.return_value.get_subject.return_value = '' + return m + root = fake_msg('root-id') + reply = fake_msg('reply-id') + + fake_thread = mock.Mock() + fake_thread.get_toplevel_messages.return_value = [root] + fake_thread.get_replies_to.side_effect = ( + lambda m: [reply] if m is root else []) + + attr = ('default', '', 'default', 'default', 'default', 'default') + settings_get = { + 'msg_summary_hides_threadwide_tags': True, + 'msg_summary_show_subject': 'never', + } + with mock.patch('alot.widgets.thread.settings') as s: + s.get.side_effect = lambda k: settings_get[k] + s.get_theming_attribute.return_value = attr + from alot.widgets.thread import ThreadTree + tree = ThreadTree(fake_thread) + + self.assertIsNone(tree['root-id']._parent_message) + self.assertIs(tree['reply-id']._parent_message, root) + + def test_different_mode_handles_mailing_list_tags(self): + """In 'different' mode, ``[list-name]`` tags must not cause replies + to look spuriously different from their parent (regression for + the bug where every reply on a list was shown).""" + widget = _make_summary_widget( + subject_mode='different', + msg_subject='[list-name] Re: Hello', + parent_subject='[list-name] Hello') + texts = _column_texts(widget) + self.assertNotIn('[list-name] Re: Hello', texts) + + def test_subject_attr_picks_subject_even_for_even_rows(self): + """An even row's subject column must be styled with the + 'subject_even' theme attribute so its background matches the + even row background. Mirrors the pattern used by even/odd line + attrs elsewhere in the theme.""" + msg = mock.Mock() + msg.get_author.return_value = ('Alice', 'a@b.c') + msg.get_datestring.return_value = '' + msg.get_subject.return_value = 'Hello' + msg.get_tags.return_value = [] + msg.get_thread.return_value.get_tags.return_value = set() + msg.get_thread.return_value.get_subject.return_value = '' + + attr = ('default', '', 'default', 'default', 'default', 'default') + settings_get = { + 'msg_summary_hides_threadwide_tags': True, + 'msg_summary_show_subject': 'always', + } + with mock.patch('alot.widgets.thread.settings') as s: + s.get.side_effect = lambda k: settings_get[k] + s.get_theming_attribute.return_value = attr + from alot.widgets.thread import MessageSummaryWidget + MessageSummaryWidget(msg, even=True) + keys = [c.args[2] + for c in s.get_theming_attribute.call_args_list + if c.args[:2] == ('thread', 'summary')] + self.assertIn('subject_even', keys) + self.assertNotIn('subject_odd', keys) + + def test_subject_attr_picks_subject_odd_for_odd_rows(self): + """An odd row's subject column must use 'subject_odd'.""" + msg = mock.Mock() + msg.get_author.return_value = ('Alice', 'a@b.c') + msg.get_datestring.return_value = '' + msg.get_subject.return_value = 'Hello' + msg.get_tags.return_value = [] + msg.get_thread.return_value.get_tags.return_value = set() + msg.get_thread.return_value.get_subject.return_value = '' + + attr = ('default', '', 'default', 'default', 'default', 'default') + settings_get = { + 'msg_summary_hides_threadwide_tags': True, + 'msg_summary_show_subject': 'always', + } + with mock.patch('alot.widgets.thread.settings') as s: + s.get.side_effect = lambda k: settings_get[k] + s.get_theming_attribute.return_value = attr + from alot.widgets.thread import MessageSummaryWidget + MessageSummaryWidget(msg, even=False) + keys = [c.args[2] + for c in s.get_theming_attribute.call_args_list + if c.args[:2] == ('thread', 'summary')] + self.assertIn('subject_odd', keys) + self.assertNotIn('subject_even', keys) + + def test_subject_column_precedes_tag_widgets(self): + """The subject column must be inserted before any TagWidget so it is + truncated last when the line is short.""" + from alot.widgets.globals import TagWidget + widget = _make_summary_widget( + subject_mode='always', + msg_subject='Hello world', + msg_tags=['inbox']) + widgets = _column_widgets(widget) + subject_indexes = [i for i, w in enumerate(widgets) + if isinstance(w, urwid.Text) + and 'Hello world' in w.text] + tag_indexes = [i for i, w in enumerate(widgets) + if isinstance(w, TagWidget)] + self.assertTrue(subject_indexes, 'subject column missing') + self.assertTrue(tag_indexes, 'tag widget missing') + self.assertLess(max(subject_indexes), min(tag_indexes))