Skip to content
Closed
59 changes: 53 additions & 6 deletions alot/commands/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@
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.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
from ..db.attachment import Attachment
from ..db.errors import DatabaseROError
Expand Down Expand Up @@ -506,24 +509,36 @@ 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': '*'})])
@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"""
repeatable = True

def __init__(self, query=None, visible=None, raw=None, all_headers=None,
indent=None, **kwargs):
indent=None, mimetree=None, mimepart=False, **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:
Expand All @@ -532,6 +547,8 @@ 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
self.mimepart = mimepart
Command.__init__(self, **kwargs)

def apply(self, ui):
Expand Down Expand Up @@ -577,6 +594,20 @@ 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:
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 \
if self.mimetree == 'toggle' else self.mimetree


# collapse/expand depending on new 'visible' value
if visible is False:
Expand All @@ -589,6 +620,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()
Expand All @@ -600,7 +633,9 @@ 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',
'plain', 'html']}),
(['--separately'], {'action': 'store_true',
'help': 'call command once for each message'}),
(['--background'], {'action': 'store_true',
Expand Down Expand Up @@ -639,6 +674,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
Expand Down Expand Up @@ -701,15 +737,22 @@ 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 = extract_body(mail)
bodytext = extract_body_part(mimepart)
msgtext = '%s\n\n%s' % (headertext, bodytext)
pipestrings.append(msgtext)
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)))

if not self.separately:
pipestrings = [separator.join(pipestrings)]
Expand Down Expand Up @@ -1022,12 +1065,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
4 changes: 3 additions & 1 deletion alot/completion/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,9 @@ def f(completed, pos):
res = self._pathcompleter.complete(params, localpos)
elif self.mode == 'thread' and cmd in ['fold', 'unfold',
'togglesource',
'toggleheaders']:
'toggleheaders',
'togglemimetree',
'togglemimepart']:
res = self._querycompleter.complete(params, localpos)
elif self.mode == 'thread' and cmd in ['tag', 'retag', 'untag',
'toggletags']:
Expand Down
29 changes: 26 additions & 3 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 @@ -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 = []
Expand All @@ -67,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 @@ -262,10 +265,30 @@ 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`"""
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, message

@staticmethod
def _get_mime_part_info(mime_part):
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))
17 changes: 11 additions & 6 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, mimetype=None):
"""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 @@ -476,15 +476,20 @@ def extract_body(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
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':
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
42 changes: 38 additions & 4 deletions alot/widgets/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
"""
Widgets specific to thread mode
"""
import email
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
Expand All @@ -16,6 +17,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):
"""
Expand Down Expand Up @@ -80,17 +83,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)


Expand Down Expand Up @@ -160,8 +162,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):
Expand All @@ -179,6 +183,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))
Expand All @@ -203,6 +208,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))

Expand Down Expand Up @@ -316,6 +324,32 @@ 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:
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):
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, 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
Loading