Skip to content

Commit

Permalink
Selectable mime parts.
Browse files Browse the repository at this point in the history
It is suggested in pazz#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.
  • Loading branch information
ryneeverett committed Mar 23, 2020
1 parent aaff899 commit 7c2906f
Show file tree
Hide file tree
Showing 7 changed files with 112 additions and 67 deletions.
14 changes: 10 additions & 4 deletions alot/commands/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -721,7 +723,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)

Expand Down Expand Up @@ -1036,12 +1038,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'))

Expand Down
9 changes: 5 additions & 4 deletions alot/db/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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`"""
Expand All @@ -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):
Expand Down
9 changes: 7 additions & 2 deletions alot/db/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <prefer-plaintext>`
to determine if a "text/plain" alternative is preferred over a "text/html"
Expand All @@ -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 = ""
rendered_payload = render_part(
body_part,
Expand Down
5 changes: 4 additions & 1 deletion alot/widgets/ansi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 11 additions & 2 deletions alot/widgets/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""
Widgets specific to thread mode
"""
import email
import logging
import urwid

Expand Down Expand Up @@ -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):
"""
Expand Down
11 changes: 11 additions & 0 deletions docs/source/usage/modes/thread.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -175,6 +176,16 @@ The following commands are available in thread mode:
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
Expand Down
118 changes: 64 additions & 54 deletions tests/db/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
from ..utilities import make_key, make_uid, TestCaseClassCleanup


def set_basic_headers(mail):
mail['Subject'] = 'Test email'
mail['To'] = '[email protected]'
mail['From'] = '[email protected]'


class TestGetParams(unittest.TestCase):

mailstring = '\n'.join([
Expand Down Expand Up @@ -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'] = '[email protected]'
mail['From'] = '[email protected]'

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(
'<!DOCTYPE html><html><body>This is an html email</body></html>',
Expand All @@ -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)

Expand All @@ -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 = '<!DOCTYPE html><html><body>This is an html email</body></html>\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(
'<!DOCTYPE html><html><body>This is an html email</body></html>',
subtype='html')
Expand All @@ -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 = '<!DOCTYPE html><html><body>This is an html email</body></html>\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)

Expand All @@ -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 = '<!DOCTYPE html><html><body>This is an html email</body></html>\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)

Expand All @@ -705,7 +711,8 @@ 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)
Expand All @@ -716,8 +723,11 @@ def test_simple_utf8_file(self):
None, {'view': 'sed "s/ is/ was/"'})))
def test_plaintext_mailcap(self):
expected = 'This was an email\n'
mail = self._make_mixed_plain_html()
actual = utils.extract_body(mail)
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)

self.assertEqual(actual, expected)

Expand Down

0 comments on commit 7c2906f

Please sign in to comment.