From 2e3c0c236e37151901050ff5b46690f3f31052a9 Mon Sep 17 00:00:00 2001 From: Lucas Hoffmann Date: Tue, 13 Sep 2016 22:12:54 +0200 Subject: [PATCH 01/13] Add togglemimetree command --- alot/commands/thread.py | 11 +++++++++-- alot/completion/command.py | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/alot/commands/thread.py b/alot/commands/thread.py index 07a598f74..2e673dc42 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -506,24 +506,31 @@ async def apply(self, ui): MODE, 'indent', help='change message/reply indentation', arguments=[(['indent'], {'action': cargparse.ValidatedStoreAction, 'validator': cargparse.is_int_or_pm})]) +@registerCommand( + MODE, 'togglemimetree', help='disply mime tree of the message', + forced={'mimetree': 'toggle'}, + arguments=[(['query'], {'help': 'query used to filter messages to affect', + 'nargs': '*'})]) class ChangeDisplaymodeCommand(Command): """fold or unfold messages""" repeatable = True def __init__(self, query=None, visible=None, raw=None, all_headers=None, - indent=None, **kwargs): + indent=None, mimetree=None, **kwargs): """ :param query: notmuch query string used to filter messages to affect :type query: str :param visible: unfold if `True`, fold if `False`, ignore if `None` :type visible: True, False, 'toggle' or None - :param raw: display raw message text. + :param raw: display raw message text :type raw: True, False, 'toggle' or None :param all_headers: show all headers (only visible if not in raw mode) :type all_headers: True, False, 'toggle' or None :param indent: message/reply indentation :type indent: '+', '-', or int + :param mimetree: show the mime tree of the message + :type mimetree: True, False, 'toggle' or None """ self.query = None if query: diff --git a/alot/completion/command.py b/alot/completion/command.py index 2ba6d5411..def5f9052 100644 --- a/alot/completion/command.py +++ b/alot/completion/command.py @@ -193,7 +193,8 @@ def f(completed, pos): res = self._pathcompleter.complete(params, localpos) elif self.mode == 'thread' and cmd in ['fold', 'unfold', 'togglesource', - 'toggleheaders']: + 'toggleheaders', + 'togglemimetree']: res = self._querycompleter.complete(params, localpos) elif self.mode == 'thread' and cmd in ['tag', 'retag', 'untag', 'toggletags']: From b4bd2cfc70b5d38a66cd5c9a9944730d884598da Mon Sep 17 00:00:00 2001 From: Lucas Hoffmann Date: Wed, 14 Sep 2016 05:55:41 +0200 Subject: [PATCH 02/13] Implement togglemimetree analogous to other commands The command is implemented analogous to togglesource and similar commands. But this commit only contains the toggleing logic and no generation of actual mime trees. --- alot/commands/thread.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/alot/commands/thread.py b/alot/commands/thread.py index 2e673dc42..87df680b3 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -539,6 +539,7 @@ def __init__(self, query=None, visible=None, raw=None, all_headers=None, self.raw = raw self.all_headers = all_headers self.indent = indent + self.mimetree = mimetree Command.__init__(self, **kwargs) def apply(self, ui): @@ -584,6 +585,11 @@ def matches(msgt): raw = not mt.display_source if self.raw == 'toggle' else self.raw all_headers = not mt.display_all_headers \ if self.all_headers == 'toggle' else self.all_headers + if self.mimetree == 'toggle': + tbuffer.focus_selected_message() + mimetree = not mt.display_mimetree \ + if self.mimetree == 'toggle' else self.mimetree + # collapse/expand depending on new 'visible' value if visible is False: @@ -596,6 +602,8 @@ def matches(msgt): mt.display_source = raw if all_headers is not None: mt.display_all_headers = all_headers + if mimetree is not None: + mt.display_mimetree = mimetree mt.debug() # let the messagetree reassemble itself mt.reassemble() From fa4f0726280469e1c6f33bcac026de5aaa7daefe Mon Sep 17 00:00:00 2001 From: Lucas Hoffmann Date: Wed, 14 Sep 2016 07:27:41 +0200 Subject: [PATCH 03/13] Add widget logic to display mime trees --- alot/widgets/thread.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/alot/widgets/thread.py b/alot/widgets/thread.py index 516510713..360ffab9d 100644 --- a/alot/widgets/thread.py +++ b/alot/widgets/thread.py @@ -160,8 +160,10 @@ def __init__(self, message, odd=True): self._all_headers_tree = None self._default_headers_tree = None self.display_attachments = True + self._mimetree = None self._attachments = None self._maintree = SimpleTree(self._assemble_structure(True)) + self.display_mimetree = False CollapsibleTree.__init__(self, self._maintree) def get_message(self): @@ -179,6 +181,7 @@ def debug(self): logging.debug('display_source %s', self.display_source) logging.debug('display_all_headers %s', self.display_all_headers) logging.debug('display_attachements %s', self.display_attachments) + logging.debug('display_mimetree %s', self.display_mimetree) logging.debug('AHT %s', str(self._all_headers_tree)) logging.debug('DHT %s', str(self._default_headers_tree)) logging.debug('MAINTREE %s', str(self._maintree._treelist)) @@ -203,6 +206,9 @@ def _assemble_structure(self, summary_only=False): mainstruct = [] if self.display_source: mainstruct.append((self._get_source(), None)) + elif self.display_mimetree: + mainstruct.append((self._get_headers(), None)) + mainstruct.append((self._get_mimetree(), None)) else: mainstruct.append((self._get_headers(), None)) @@ -316,6 +322,12 @@ def construct_header_pile(self, headers=None, normalize=True): gaps_att = settings.get_theming_attribute('thread', 'header') return DictList(lines, key_att, value_att, gaps_att) + def _get_mimetree(self): + if self._mimetree is None: + mime_tree = self._message.get_mime_tree() + self._mimetree = SimpleTree([mime_tree]) + return self._mimetree + class ThreadTree(Tree): """ From 0bf07351f5403b24ec7ad89c528332d069dc5afb Mon Sep 17 00:00:00 2001 From: Lucas Hoffmann Date: Tue, 27 Dec 2016 21:27:47 +0100 Subject: [PATCH 04/13] Generate simple mime trees Only minimal information and no indenting is shown until now. --- alot/db/message.py | 19 +++++++++++++++++++ alot/widgets/thread.py | 15 +++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/alot/db/message.py b/alot/db/message.py index 0926e9f47..6b969d3a0 100644 --- a/alot/db/message.py +++ b/alot/db/message.py @@ -46,6 +46,7 @@ def __init__(self, dbman, msg, thread=None): self._filename = msg.get_filename() self._email = None # will be read upon first use self._attachments = None # will be read upon first use + self._mime_tree = None # will be read upon first use self._tags = set(msg.get_tags()) self._session_keys = [] @@ -269,3 +270,21 @@ def matches(self, querystring): """tests if this messages is in the resultset for `querystring`""" searchfor = '( {} ) AND id:{}'.format(querystring, self._id) return self._dbman.count_messages(searchfor) > 0 + + def get_mime_tree(self): + if not self._mime_tree: + self._mime_tree = self._get_mimetree(self.get_email()) + return self._mime_tree + + @classmethod + def _get_mimetree(cls, message): + label = cls._get_mime_part_info(message) + if message.is_multipart(): + return label, [cls._get_mimetree(m) for m in message.get_payload()] + else: + return label, None + + @staticmethod + def _get_mime_part_info(mime_part): + return '{}: {}'.format(mime_part.get_content_type(), + mime_part.get_filename() or '(no filename)') diff --git a/alot/widgets/thread.py b/alot/widgets/thread.py index 360ffab9d..87e3caa42 100644 --- a/alot/widgets/thread.py +++ b/alot/widgets/thread.py @@ -324,10 +324,21 @@ def construct_header_pile(self, headers=None, normalize=True): def _get_mimetree(self): if self._mimetree is None: - mime_tree = self._message.get_mime_tree() - self._mimetree = SimpleTree([mime_tree]) + mime_tree_txt = self._message.get_mime_tree() + mime_tree_widgets = self._text_tree_to_widget_tree(mime_tree_txt) + self._mimetree = SimpleTree([mime_tree_widgets]) return self._mimetree + def _text_tree_to_widget_tree(self, tree): + att = settings.get_theming_attribute('thread', 'body') + att_focus = settings.get_theming_attribute('thread', 'body_focus') + label, subtrees = tree + label = FocusableText(label, att, att_focus) + if subtrees is None: + return label, None + else: + return label, [self._text_tree_to_widget_tree(s) for s in subtrees] + class ThreadTree(Tree): """ From 5a5136a9f6711ab4b3d57599081184cab8f79618 Mon Sep 17 00:00:00 2001 From: Lucas Hoffmann Date: Tue, 27 Dec 2016 21:33:03 +0100 Subject: [PATCH 05/13] Use ArrowTree to indent mime tree --- alot/widgets/thread.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/alot/widgets/thread.py b/alot/widgets/thread.py index 87e3caa42..7ddb1dd41 100644 --- a/alot/widgets/thread.py +++ b/alot/widgets/thread.py @@ -7,7 +7,7 @@ import logging import urwid -from urwidtrees import Tree, SimpleTree, CollapsibleTree +from urwidtrees import Tree, SimpleTree, CollapsibleTree, ArrowTree from .ansi import ANSIText from .globals import TagWidget @@ -324,9 +324,10 @@ def construct_header_pile(self, headers=None, normalize=True): def _get_mimetree(self): if self._mimetree is None: - mime_tree_txt = self._message.get_mime_tree() - mime_tree_widgets = self._text_tree_to_widget_tree(mime_tree_txt) - self._mimetree = SimpleTree([mime_tree_widgets]) + tree = self._message.get_mime_tree() + tree = self._text_tree_to_widget_tree(tree) + tree = SimpleTree([tree]) + self._mimetree = ArrowTree(tree) return self._mimetree def _text_tree_to_widget_tree(self, tree): From 47f9bf299a1917bc01ed2caab253a855e7cce19b Mon Sep 17 00:00:00 2001 From: Lucas Hoffmann Date: Tue, 27 Dec 2016 22:11:30 +0100 Subject: [PATCH 06/13] Put more info into the mime tree --- alot/db/message.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/alot/db/message.py b/alot/db/message.py index 6b969d3a0..02c059f60 100644 --- a/alot/db/message.py +++ b/alot/db/message.py @@ -286,5 +286,8 @@ def _get_mimetree(cls, message): @staticmethod def _get_mime_part_info(mime_part): - return '{}: {}'.format(mime_part.get_content_type(), - mime_part.get_filename() or '(no filename)') + contenttype = mime_part.get_content_type() + filename = mime_part.get_filename() or '(no filename)' + charset = mime_part.get_content_charset() or '' + size = helper.humanize_size(len(mime_part.as_string())) + return ' '.join((contenttype, filename, charset, size)) From 21ddb44b5e07b449dfa53b3be4436bb059249152 Mon Sep 17 00:00:00 2001 From: ryneeverett Date: Mon, 17 Feb 2020 23:39:02 +0000 Subject: [PATCH 07/13] mimetree-rebase: FocusText -> ANSIText (See #1015) --- alot/widgets/thread.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/alot/widgets/thread.py b/alot/widgets/thread.py index 7ddb1dd41..8c8df7c92 100644 --- a/alot/widgets/thread.py +++ b/alot/widgets/thread.py @@ -16,6 +16,8 @@ from ..db.utils import decode_header, X_SIGNATURE_MESSAGE_HEADER from ..helper import string_sanitize +ANSI_BACKGROUND = settings.get("interpret_ansi_background") + class MessageSummaryWidget(urwid.WidgetWrap): """ @@ -80,17 +82,16 @@ def __init__(self, content, attr=None, attr_focus=None): for each line in content. """ structure = [] - ansi_background = settings.get("interpret_ansi_background") # depending on this config setting, we either add individual lines # or the complete context as focusable objects. if settings.get('thread_focus_linewise'): for line in content.splitlines(): structure.append((ANSIText(line, attr, attr_focus, - ansi_background), None)) + ANSI_BACKGROUND), None)) else: structure.append((ANSIText(content, attr, attr_focus, - ansi_background), None)) + ANSI_BACKGROUND), None)) SimpleTree.__init__(self, structure) @@ -334,7 +335,7 @@ def _text_tree_to_widget_tree(self, tree): att = settings.get_theming_attribute('thread', 'body') att_focus = settings.get_theming_attribute('thread', 'body_focus') label, subtrees = tree - label = FocusableText(label, att, att_focus) + label = ANSIText(label, att, att_focus, ANSI_BACKGROUND) if subtrees is None: return label, None else: From 4401e2cd7b0fbed574706338eec64462c0f31f78 Mon Sep 17 00:00:00 2001 From: ryneeverett Date: Mon, 2 Mar 2020 17:26:28 +0000 Subject: [PATCH 08/13] Selectable mime parts. It is suggested in #862: > something like select to display a rendered version of the current mime part > (either inside the tree or in a new buffer) I played around with these options before arriving at the current behavior. Adding the mime part content to the mimetree seemed made for a very busy/over-nested screen and didn't seem all that useful. Likewise opening in another buffer doesn't seem useful and might need a new Buffer subclass in order to be well labeled. The `select` behavior I ended up going with was to change the mime part chosen by default in the Message itself. This should make implementing other commands (e.g. pipeto) on the mime parts trivial. I took `select` a step further by also having it conveniently togglemimetree off. I can't think of any use case for remaining in the mimetree view after making a selection. --- alot/commands/thread.py | 14 +++-- alot/db/message.py | 9 ++-- alot/db/utils.py | 9 +++- alot/widgets/ansi.py | 5 +- alot/widgets/thread.py | 13 ++++- tests/db/test_utils.py | 112 +++++++++++++++++++++------------------- 6 files changed, 97 insertions(+), 65 deletions(-) diff --git a/alot/commands/thread.py b/alot/commands/thread.py index 87df680b3..c23ebb582 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -29,7 +29,6 @@ from ..db.utils import decode_header from ..db.utils import formataddr from ..db.utils import extract_headers -from ..db.utils import extract_body from ..db.utils import clear_my_address from ..db.utils import ensure_unique_address from ..db.envelope import Envelope @@ -517,7 +516,7 @@ class ChangeDisplaymodeCommand(Command): repeatable = True def __init__(self, query=None, visible=None, raw=None, all_headers=None, - indent=None, mimetree=None, **kwargs): + indent=None, mimetree=None, mimepart=False, **kwargs): """ :param query: notmuch query string used to filter messages to affect :type query: str @@ -540,6 +539,7 @@ def __init__(self, query=None, visible=None, raw=None, all_headers=None, self.all_headers = all_headers self.indent = indent self.mimetree = mimetree + self.mimepart = mimepart Command.__init__(self, **kwargs) def apply(self, ui): @@ -585,6 +585,8 @@ def matches(msgt): raw = not mt.display_source if self.raw == 'toggle' else self.raw all_headers = not mt.display_all_headers \ if self.all_headers == 'toggle' else self.all_headers + if self.mimepart: + mt.set_mimepart(ui.get_deep_focus().mimepart) if self.mimetree == 'toggle': tbuffer.focus_selected_message() mimetree = not mt.display_mimetree \ @@ -722,7 +724,7 @@ async def apply(self, ui): pipestrings.append(mail.as_string()) elif self.output_format == 'decoded': headertext = extract_headers(mail) - bodytext = extract_body(mail) + bodytext = msg.get_body_text() msgtext = '%s\n\n%s' % (headertext, bodytext) pipestrings.append(msgtext) @@ -1037,12 +1039,16 @@ class ThreadSelectCommand(Command): """select focussed element: - if it is a message summary, toggle visibility of the message; - - if it is an attachment line, open the attachment""" + - if it is an attachment line, open the attachment + - if it is a mimepart, toggle visibility of the mimepart""" async def apply(self, ui): focus = ui.get_deep_focus() if isinstance(focus, AttachmentWidget): logging.info('open attachment') await ui.apply_command(OpenAttachmentCommand(focus.get_attachment())) + elif getattr(focus, 'mimepart', False): + await ui.apply_command(ChangeDisplaymodeCommand( + mimepart=True, mimetree='toggle')) else: await ui.apply_command(ChangeDisplaymodeCommand(visible='toggle')) diff --git a/alot/db/message.py b/alot/db/message.py index 02c059f60..9f5fafb59 100644 --- a/alot/db/message.py +++ b/alot/db/message.py @@ -10,7 +10,7 @@ from notmuch import NullPointerError from . import utils -from .utils import extract_body +from .utils import get_body_part, extract_body_part from .utils import decode_header from .attachment import Attachment from .. import helper @@ -68,6 +68,8 @@ def __init__(self, dbman, msg, thread=None): else: self._from = '"Unknown" <>' + self.mime_part = get_body_part(self.get_email()) + def __str__(self): """prettyprint the message""" aname, aaddress = self.get_author() @@ -263,8 +265,7 @@ def get_attachments(self): def get_body_text(self): """ returns bodystring extracted from this mail """ - # TODO: allow toggle commands to decide which part is considered body - return extract_body(self.get_email()) + return extract_body_part(self.mime_part) def matches(self, querystring): """tests if this messages is in the resultset for `querystring`""" @@ -282,7 +283,7 @@ def _get_mimetree(cls, message): if message.is_multipart(): return label, [cls._get_mimetree(m) for m in message.get_payload()] else: - return label, None + return label, message @staticmethod def _get_mime_part_info(mime_part): diff --git a/alot/db/utils.py b/alot/db/utils.py index fafd9c348..ef6567425 100644 --- a/alot/db/utils.py +++ b/alot/db/utils.py @@ -463,8 +463,8 @@ def remove_cte(part, as_string=False): "http://alot.rtfd.io/en/latest/faq.html") -def extract_body(mail): - """Returns a string view of a Message. +def get_body_part(mail): + """Returns an EmailMessage. This consults :ref:`prefer_plaintext ` to determine if a "text/plain" alternative is preferred over a "text/html" @@ -485,6 +485,11 @@ def extract_body(mail): if body_part is None: # if no part matching preferredlist was found return "" + return body_part + + +def extract_body_part(body_part): + """Returns a string view of a Message.""" displaystring = "" if body_part.get_content_type() == 'text/plain': diff --git a/alot/widgets/ansi.py b/alot/widgets/ansi.py index 97a4fb95c..6301487b7 100644 --- a/alot/widgets/ansi.py +++ b/alot/widgets/ansi.py @@ -12,7 +12,10 @@ class ANSIText(urwid.WidgetWrap): def __init__(self, txt, default_attr=None, default_attr_focus=None, - ansi_background=True, **kwds): + ansi_background=True, + mimepart=False, + **kwds): + self.mimepart = mimepart ct, focus_map = parse_escapes_to_urwid(txt, default_attr, default_attr_focus, ansi_background) diff --git a/alot/widgets/thread.py b/alot/widgets/thread.py index 8c8df7c92..105189d39 100644 --- a/alot/widgets/thread.py +++ b/alot/widgets/thread.py @@ -4,6 +4,7 @@ """ Widgets specific to thread mode """ +import email import logging import urwid @@ -334,13 +335,21 @@ def _get_mimetree(self): def _text_tree_to_widget_tree(self, tree): att = settings.get_theming_attribute('thread', 'body') att_focus = settings.get_theming_attribute('thread', 'body_focus') + mimepart = tree[1] if isinstance( + tree[1], email.message.EmailMessage) else None label, subtrees = tree - label = ANSIText(label, att, att_focus, ANSI_BACKGROUND) - if subtrees is None: + label = ANSIText( + label, att, att_focus, ANSI_BACKGROUND, mimepart=mimepart) + if subtrees is None or mimepart: return label, None else: return label, [self._text_tree_to_widget_tree(s) for s in subtrees] + def set_mimepart(self, mimepart): + """ Set message widget mime part and invalidate body tree.""" + self.get_message().mime_part = mimepart + self._bodytree = None + class ThreadTree(Tree): """ diff --git a/tests/db/test_utils.py b/tests/db/test_utils.py index 98a8247c6..538289eb4 100644 --- a/tests/db/test_utils.py +++ b/tests/db/test_utils.py @@ -28,6 +28,12 @@ from ..utilities import make_key, make_uid, TestCaseClassCleanup +def set_basic_headers(mail): + mail['Subject'] = 'Test email' + mail['To'] = 'foo@example.com' + mail['From'] = 'bar@example.com' + + class TestGetParams(unittest.TestCase): mailstring = '\n'.join([ @@ -598,51 +604,11 @@ def test_encrypted_signed_in_multipart_mixed(self): self.assertIn(utils.X_SIGNATURE_MESSAGE_HEADER, m) -class TestExtractBody(unittest.TestCase): - - @staticmethod - def _set_basic_headers(mail): - mail['Subject'] = 'Test email' - mail['To'] = 'foo@example.com' - mail['From'] = 'bar@example.com' - - def test_single_text_plain(self): - mail = EmailMessage() - self._set_basic_headers(mail) - mail.set_content('This is an email') - actual = utils.extract_body(mail) - - expected = 'This is an email\n' - - self.assertEqual(actual, expected) - - @unittest.expectedFailure - # This makes no sense - def test_two_text_plain(self): - mail = email.mime.multipart.MIMEMultipart() - self._set_basic_headers(mail) - mail.attach(email.mime.text.MIMEText('This is an email')) - mail.attach(email.mime.text.MIMEText('This is a second part')) - - actual = utils.extract_body(mail) - expected = 'This is an email\n\nThis is a second part' - - self.assertEqual(actual, expected) - - def test_text_plain_with_attachment_text(self): - mail = EmailMessage() - self._set_basic_headers(mail) - mail.set_content('This is an email') - mail.add_attachment('this shouldnt be displayed') - - actual = utils.extract_body(mail) - expected = 'This is an email\n' - - self.assertEqual(actual, expected) +class TestGetBodyPart(unittest.TestCase): def _make_mixed_plain_html(self): mail = EmailMessage() - self._set_basic_headers(mail) + set_basic_headers(mail) mail.set_content('This is an email') mail.add_alternative( 'This is an html email', @@ -651,9 +617,9 @@ def _make_mixed_plain_html(self): @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=True)) def test_prefer_plaintext_mixed(self): - expected = 'This is an email\n' + expected = "text/plain" mail = self._make_mixed_plain_html() - actual = utils.extract_body(mail) + actual = utils.get_body_part(mail).get_content_type() self.assertEqual(actual, expected) @@ -663,15 +629,15 @@ def test_prefer_plaintext_mixed(self): @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=(None, {'view': 'cat'}))) def test_prefer_html_mixed(self): - expected = 'This is an html email\n' + expected = 'text/html' mail = self._make_mixed_plain_html() - actual = utils.extract_body(mail) + actual = utils.get_body_part(mail).get_content_type() self.assertEqual(actual, expected) def _make_html_only(self): mail = EmailMessage() - self._set_basic_headers(mail) + set_basic_headers(mail) mail.set_content( 'This is an html email', subtype='html') @@ -681,9 +647,9 @@ def _make_html_only(self): @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=(None, {'view': 'cat'}))) def test_prefer_plaintext_only(self): - expected = 'This is an html email\n' + expected = 'text/html' mail = self._make_html_only() - actual = utils.extract_body(mail) + actual = utils.get_body_part(mail).get_content_type() self.assertEqual(actual, expected) @@ -693,9 +659,49 @@ def test_prefer_plaintext_only(self): @mock.patch('alot.db.utils.settings.mailcap_find_match', mock.Mock(return_value=(None, {'view': 'cat'}))) def test_prefer_html_only(self): - expected = 'This is an html email\n' + expected = 'text/html' mail = self._make_html_only() - actual = utils.extract_body(mail) + actual = utils.get_body_part(mail).get_content_type() + + self.assertEqual(actual, expected) + + +class TestExtractBodyPart(unittest.TestCase): + + def test_single_text_plain(self): + mail = EmailMessage() + set_basic_headers(mail) + mail.set_content('This is an email') + body_part = utils.get_body_part(mail) + actual = utils.extract_body_part(body_part) + + expected = 'This is an email\n' + + self.assertEqual(actual, expected) + + @unittest.expectedFailure + # This makes no sense + def test_two_text_plain(self): + mail = email.mime.multipart.MIMEMultipart() + set_basic_headers(mail) + mail.attach(email.mime.text.MIMEText('This is an email')) + mail.attach(email.mime.text.MIMEText('This is a second part')) + body_part = utils.get_body_part(mail) + + actual = utils.extract_body(body_part) + expected = 'This is an email\n\nThis is a second part' + + self.assertEqual(actual, expected) + + def test_text_plain_with_attachment_text(self): + mail = EmailMessage() + set_basic_headers(mail) + mail.set_content('This is an email') + mail.add_attachment('this shouldnt be displayed') + body_part = utils.get_body_part(mail) + + actual = utils.extract_body_part(body_part) + expected = 'This is an email\n' self.assertEqual(actual, expected) @@ -703,11 +709,13 @@ def test_simple_utf8_file(self): mail = email.message_from_binary_file( open('tests/static/mail/utf8.eml', 'rb'), _class=email.message.EmailMessage) - actual = utils.extract_body(mail) + body_part = utils.get_body_part(mail) + actual = utils.extract_body_part(body_part) expected = "Liebe Grüße!\n" self.assertEqual(actual, expected) + class TestMessageFromString(unittest.TestCase): """Tests for decrypted_message_from_string. From 2bbd0b0c09cce552b55bc85f14e867ed161e4ae8 Mon Sep 17 00:00:00 2001 From: ryneeverett Date: Tue, 3 Mar 2020 16:16:42 +0000 Subject: [PATCH 09/13] Add pipeto --format=mimepart option. The most notable use case is piping html to a browser without extra scripts such as those shared in #789. --- alot/commands/thread.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/alot/commands/thread.py b/alot/commands/thread.py index c23ebb582..c049ba81e 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -31,6 +31,8 @@ from ..db.utils import extract_headers from ..db.utils import clear_my_address from ..db.utils import ensure_unique_address +from ..db.utils import remove_cte +from ..db.utils import string_sanitize from ..db.envelope import Envelope from ..db.attachment import Attachment from ..db.errors import DatabaseROError @@ -617,7 +619,8 @@ def matches(msgt): (['cmd'], {'help': 'shellcommand to pipe to', 'nargs': '+'}), (['--all'], {'action': 'store_true', 'help': 'pass all messages'}), (['--format'], {'help': 'output format', 'default': 'raw', - 'choices': ['raw', 'decoded', 'id', 'filepath']}), + 'choices': [ + 'raw', 'decoded', 'id', 'filepath', 'mimepart']}), (['--separately'], {'action': 'store_true', 'help': 'call command once for each message'}), (['--background'], {'action': 'store_true', @@ -656,6 +659,7 @@ def __init__(self, cmd, all=False, separately=False, background=False, 'decoded': message content, decoded quoted printable, 'id': message ids, separated by newlines, 'filepath': paths to message files on disk + 'mimepart': only pipe the currently selected mime part :type format: str :param add_tags: add 'Tags' header to the message :type add_tags: bool @@ -727,6 +731,9 @@ async def apply(self, ui): bodytext = msg.get_body_text() msgtext = '%s\n\n%s' % (headertext, bodytext) pipestrings.append(msgtext) + elif self.output_format == 'mimepart': + pipestrings.append(string_sanitize(remove_cte( + msg.mime_part, as_string=True))) if not self.separately: pipestrings = [separator.join(pipestrings)] From 9362c9ef9b87afea01802aa785c33adb4f63a0c6 Mon Sep 17 00:00:00 2001 From: ryneeverett Date: Wed, 11 Mar 2020 15:52:18 +0000 Subject: [PATCH 10/13] Pipe focused mime part from the mime tree. When the mimetree is toggled on and a mime part is focused, pipeto should pipe the focused mime part rather than the currently selected part. This is only applicable to --format's that pipe a single part, which are decoded and mimepart. --- alot/commands/thread.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/alot/commands/thread.py b/alot/commands/thread.py index c049ba81e..ac38c3538 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -31,6 +31,7 @@ from ..db.utils import extract_headers from ..db.utils import clear_my_address from ..db.utils import ensure_unique_address +from ..db.utils import extract_body_part from ..db.utils import remove_cte from ..db.utils import string_sanitize from ..db.envelope import Envelope @@ -722,18 +723,20 @@ async def apply(self, ui): else: for msg in to_print: mail = msg.get_email() + mimepart = getattr( + ui.get_deep_focus(), 'mimepart', False) or msg.mime_part if self.add_tags: mail.add_header('Tags', ', '.join(msg.get_tags())) if self.output_format == 'raw': pipestrings.append(mail.as_string()) elif self.output_format == 'decoded': headertext = extract_headers(mail) - bodytext = msg.get_body_text() + bodytext = extract_body_part(mimepart) msgtext = '%s\n\n%s' % (headertext, bodytext) pipestrings.append(msgtext) elif self.output_format == 'mimepart': pipestrings.append(string_sanitize(remove_cte( - msg.mime_part, as_string=True))) + mimepart, as_string=True))) if not self.separately: pipestrings = [separator.join(pipestrings)] From c3b259f7238e1dcd1edb5fe86f99fe547a8b2882 Mon Sep 17 00:00:00 2001 From: ryneeverett Date: Thu, 5 Mar 2020 19:29:22 +0000 Subject: [PATCH 11/13] Pipeto specific mime part format. While `pipeto --format mimepart` pipes whichever part is currently selected, the 'html' and 'plain' formats override the selected mime part (and preferences in settings). This is useful because the mime type you want displayed in alot isn't necessarily the one you want piped and also for setting keybindings to pipe specific mime types to specific applications (plain -> text editor, html -> web browser). --- alot/commands/thread.py | 8 ++++++-- alot/db/utils.py | 10 +++++----- tests/db/test_utils.py | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/alot/commands/thread.py b/alot/commands/thread.py index ac38c3538..a123211cd 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -32,6 +32,7 @@ from ..db.utils import clear_my_address from ..db.utils import ensure_unique_address from ..db.utils import extract_body_part +from ..db.utils import get_body_part from ..db.utils import remove_cte from ..db.utils import string_sanitize from ..db.envelope import Envelope @@ -621,7 +622,8 @@ def matches(msgt): (['--all'], {'action': 'store_true', 'help': 'pass all messages'}), (['--format'], {'help': 'output format', 'default': 'raw', 'choices': [ - 'raw', 'decoded', 'id', 'filepath', 'mimepart']}), + 'raw', 'decoded', 'id', 'filepath', 'mimepart', + 'plain', 'html']}), (['--separately'], {'action': 'store_true', 'help': 'call command once for each message'}), (['--background'], {'action': 'store_true', @@ -734,7 +736,9 @@ async def apply(self, ui): bodytext = extract_body_part(mimepart) msgtext = '%s\n\n%s' % (headertext, bodytext) pipestrings.append(msgtext) - elif self.output_format == 'mimepart': + elif self.output_format in ['mimepart', 'plain', 'html']: + if self.output_format in ['plain', 'html']: + mimepart = get_body_part(mail, self.output_format) pipestrings.append(string_sanitize(remove_cte( mimepart, as_string=True))) diff --git a/alot/db/utils.py b/alot/db/utils.py index ef6567425..1c5e265a7 100644 --- a/alot/db/utils.py +++ b/alot/db/utils.py @@ -463,7 +463,7 @@ def remove_cte(part, as_string=False): "http://alot.rtfd.io/en/latest/faq.html") -def get_body_part(mail): +def get_body_part(mail, mimetype=None): """Returns an EmailMessage. This consults :ref:`prefer_plaintext ` @@ -476,10 +476,10 @@ def get_body_part(mail): :rtype: str """ - if settings.get('prefer_plaintext'): - preferencelist = ('plain', 'html') - else: - preferencelist = ('html', 'plain') + if not mimetype: + mimetype = 'plain' if settings.get('prefer_plaintext') else 'html' + preferencelist = { + 'plain': ('plain', 'html'), 'html': ('html', 'plain')}[mimetype] body_part = mail.get_body(preferencelist) if body_part is None: # if no part matching preferredlist was found diff --git a/tests/db/test_utils.py b/tests/db/test_utils.py index 538289eb4..c3113f00a 100644 --- a/tests/db/test_utils.py +++ b/tests/db/test_utils.py @@ -665,6 +665,22 @@ def test_prefer_html_only(self): self.assertEqual(actual, expected) + @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=False)) + def test_prefer_html_set_mimetype_plain(self): + expected = "text/plain" + mail = self._make_mixed_plain_html() + actual = utils.get_body_part(mail, 'plain').get_content_type() + + self.assertEqual(actual, expected) + + @mock.patch('alot.db.utils.settings.get', mock.Mock(return_value=True)) + def test_prefer_plaintext_set_mimetype_html(self): + expected = 'text/html' + mail = self._make_mixed_plain_html() + actual = utils.get_body_part(mail, 'html').get_content_type() + + self.assertEqual(actual, expected) + class TestExtractBodyPart(unittest.TestCase): From 9394b825e5bd320fca636f1b9fdc535fd0729559 Mon Sep 17 00:00:00 2001 From: ryneeverett Date: Wed, 11 Mar 2020 02:36:01 +0000 Subject: [PATCH 12/13] togglemimepart: switch between html and plaintext. --- alot/commands/thread.py | 14 +++++++++++++- alot/completion/command.py | 3 ++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/alot/commands/thread.py b/alot/commands/thread.py index a123211cd..cc00bac04 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -514,6 +514,11 @@ async def apply(self, ui): forced={'mimetree': 'toggle'}, arguments=[(['query'], {'help': 'query used to filter messages to affect', 'nargs': '*'})]) +@registerCommand( + MODE, 'togglemimepart', help='switch between html and plain text message', + forced={'mimepart': 'toggle'}, + arguments=[(['query'], {'help': 'query used to filter messages to affect', + 'nargs': '*'})]) class ChangeDisplaymodeCommand(Command): """fold or unfold messages""" @@ -590,7 +595,14 @@ def matches(msgt): all_headers = not mt.display_all_headers \ if self.all_headers == 'toggle' else self.all_headers if self.mimepart: - mt.set_mimepart(ui.get_deep_focus().mimepart) + if self.mimepart == 'toggle': + message = mt.get_message() + mimetype = {'plain': 'html', 'html': 'plain'}[ + message.mime_part.get_content_subtype()] + mimepart = get_body_part(message.get_email(), mimetype) + elif self.mimepart is True: + mimepart = ui.get_deep_focus().mimepart + mt.set_mimepart(mimepart) if self.mimetree == 'toggle': tbuffer.focus_selected_message() mimetree = not mt.display_mimetree \ diff --git a/alot/completion/command.py b/alot/completion/command.py index def5f9052..22b6342a1 100644 --- a/alot/completion/command.py +++ b/alot/completion/command.py @@ -194,7 +194,8 @@ def f(completed, pos): elif self.mode == 'thread' and cmd in ['fold', 'unfold', 'togglesource', 'toggleheaders', - 'togglemimetree']: + 'togglemimetree', + 'togglemimepart']: res = self._querycompleter.complete(params, localpos) elif self.mode == 'thread' and cmd in ['tag', 'retag', 'untag', 'toggletags']: From 7521e3fa4f776bbe16918e4dfab0e2f4c781de65 Mon Sep 17 00:00:00 2001 From: ryneeverett Date: Fri, 13 Mar 2020 02:21:57 +0000 Subject: [PATCH 13/13] mimetree: Update docs. --- docs/source/usage/modes/thread.rst | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/source/usage/modes/thread.rst b/docs/source/usage/modes/thread.rst index 58888fd1b..155b50dd0 100644 --- a/docs/source/usage/modes/thread.rst +++ b/docs/source/usage/modes/thread.rst @@ -72,7 +72,7 @@ The following commands are available in thread mode: optional arguments :---all: pass all messages - :---format: output format; valid choices are: 'raw','decoded','id','filepath' (defaults to: 'raw') + :---format: output format; valid choices are: 'raw','decoded','id','filepath','mimepart','plain','html' (defaults to: 'raw') :---separately: call command once for each message :---background: don't stop the interface :---add_tags: add 'Tags' header to the message @@ -150,6 +150,7 @@ The following commands are available in thread mode: select focussed element: - if it is a message summary, toggle visibility of the message; - if it is an attachment line, open the attachment + - if it is a mimepart, toggle visibility of the mimepart .. _cmd.thread.tag: @@ -175,6 +176,26 @@ The following commands are available in thread mode: query used to filter messages to affect +.. _cmd.thread.togglemimepart: + +.. describe:: togglemimepart + + switch between html and plain text message + + argument + query used to filter messages to affect + + +.. _cmd.thread.togglemimetree: + +.. describe:: togglemimetree + + disply mime tree of the message + + argument + query used to filter messages to affect + + .. _cmd.thread.togglesource: .. describe:: togglesource