Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 14 additions & 0 deletions alot/db/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions alot/defaults/alot.rc.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions alot/defaults/default.theme
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions alot/defaults/theme.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions alot/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 39 additions & 7 deletions alot/widgets/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
19 changes: 19 additions & 0 deletions docs/source/configuration/alotrc_table
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions tests/db/test_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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é')
27 changes: 27 additions & 0 deletions tests/settings/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
54 changes: 54 additions & 0 deletions tests/test_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,57 @@ def test_parsing_working(self):
'Subject': ['Re: Hello'],
'In-reply-to': ['<C8CE9EFD-CB23-4BC0-B70D-9B7FEAD59F8C@example.org>']}, '')
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')
Loading
Loading