Skip to content
Dylan Baker edited this page Jul 25, 2018 · 57 revisions

User-Contributed Hooks

Auto-Bcc

https://github.com/pazz/alot/issues/563#issuecomment-13717496

def pre_envelope_send(ui, dbm):                                                 
        ADDR = "[email protected]"                                   
        BCC_ADDR = "[email protected]"                               
        from_fulladdr = ui.current_buffer.envelope.get_all("From")[0]           
        if ADDR in from_fulladdr:                                               
                ui.current_buffer.envelope.add("Bcc:", BCC_ADDR)

Auto-refresh search buffer

If it bothers you that a search buffer still shows unread messages after you have marked them read in another buffer and return to your search buffer, you may add the following hook:

def pre_buffer_focus(ui, dbm, buf):
	if buf.modename == 'search':
		buf.rebuild()

Save marked position in search buffer

To maintain the position of focus in a search buffer after you open another buffer and return to the search buffer use the following hooks (based on pull request #633)

def pre_buffer_open(ui, dbm, buf):
    current = ui.current_buffer
    if isinstance(current, alot.buffers.SearchBuffer):
        current.focused_thread = current.get_selected_thread()   # remember focus

def post_buffer_focus(ui, dbm, buf, success):
    if success and hasattr(buf, "focused_thread"):  # if buffer has saved focus
        tid = buf.focused_thread.get_thread_id() 
        for pos, tlw in enumerate(buf.threadlist.get_lines()):
            if tlw.get_thread().get_thread_id() == tid:
                buf.body.set_focus(pos)
                break

Check for missing attachment before sending

https://github.com/pazz/alot/issues/395#issuecomment-13859844

from twisted.internet.defer import inlineCallbacks
import re

@inlineCallbacks
def pre_envelope_send(ui, dbm, _):
    e = ui.current_buffer.envelope
    if re.match('.*[Aa]ttach', e.body, re.DOTALL) and\
       not e.attachments:
        msg = 'no attachments. send anyway?'
        if not (yield ui.choice(msg, select='yes')) == 'yes':
            raise Exception()

Change From: address based on destination

Sometimes you want to change your From address based on where is the destination. I.e. you want to use work email address for company domain, project email address when addressing some mailing list and personal address otherwise. This will override every other setting:

import re

transitions = [
    ('.*work-domain\.com.*',
     'Your Name <[email protected]>'),
    ('.*[email protected].*',
     'Your Name <[email protected]>')
    ]

addr_trans = []
for addr, fr in transitions:
    addr_trans.append((re.compile("(To|Cc): %s" % addr, re.MULTILINE),
                       "From: %s" % fr))

def pre_edit_translate(bodytext, ui, dbm):
    for addr, new_from in addr_trans:
        if addr.search(bodytext):
            return re.sub('^From: .*$', new_from, bodytext, flags=re.MULTILINE)
    return bodytext

Manually marking a Github notification as read

Github automatically marks a notification as read if one views the corresponding email in a html-aware mailclient. This is done by a getting a small invisible 1-pixel beacon image. alot does not download this image per default. If you want to manually mark some github notification as seen, you can define the hook below and triger it using call:

import re
import urllib2


def github_mark_read(ui):
    msg = ui.current_buffer.get_selected_message()
    msgtext = str(msg.get_email())
    r = r"img src='(https://github.com/notifications/beacon/.*.gif)'"
    beacons = re.findall(r, msgtext)
    if beacons:
        urllib2.urlopen(beacons[0])
        ui.notify('removed from github notifications:\n %s' % beacons[0])

If you want to mark the focussed message (in thread mode) as read by hitting $, add the following to the binding section of the config:

[[bindings]]
  [[thread]]
    $ = call hooks.github_mark_read(ui)

Automatically construct list unsubscribe mails

https://github.com/pazz/alot/issues/656

Recent branches of alot (0.3.5-feature-mailto-666) contain helpers alot.helper.parse_mailto and alot.helper.mailto_to_envelope. The hook below uses those to construct unsubscribe mails for mailing lists by checking if (in thread mode) the currently highlighted message has a List-unsubscribe header that contains a mailto-string.

With the hooks defines as below, use call hooks.unsubscribe() to trigger the function. This can of course also be bound to a keypress.

def unsubscribe():
    """ 
    Unsubscribe from a mailing list.

    This hook reads the 'List-Unsubscribe' header of a mail in thread mode,
    constructs a unsubsribe-mail according to any mailto-url it finds
    and opens the new mail in an envelope buffer.
    """
    from alot.helper import mailto_to_envelope
    from alot.buffers import EnvelopeBuffer
    msg = ui.current_buffer.get_selected_message()
    e = msg.get_email()
    uheader = e['List-Unsubscribe']
    dtheader = e.get('Delivered-To', None)

    if uheader is not None:
        M = re.search(r'<(mailto:\S*)>', uheader)
        if M is not None:
            env = mailto_to_envelope(M.group(1))
            if dtheader is not None:
                env['From'] = [dtheader]
            ui.buffer_open(EnvelopeBuffer(ui, env))
    else:
        ui.notify('focussed mail contains no \'List-Unsubscribe\' header',
                  'error')

Translate Message body via google translate (goslate):

update 09/2017: This does not work any more. Apparently google has shut down the API that the goslate package uses. "Free lunch is over. Thanks for using." (https://pypi.python.org/pypi/goslate) A possible alternative is to use the google API in combination with a dev key directly. Contributions welcome!

[bindings]
[[thread]]
', t' = "call hooks.translate(ui)"

the hook code is below. It uses google translate via goslate: https://pypi.python.org/pypi/goslate .

def translate(ui, targetlang='en'):
    # get msg content
    msg = ui.current_buffer.get_selected_message()
    msgtext = msg.accumulate_body()

    # translate
    import goslate
    gs = goslate.Goslate()
    tmsg = gs.translate(msgtext, targetlang)

    # replace message widgets content
    mt=ui.current_buffer.get_selected_messagetree()
    mt.replace_bodytext(tmsg)
    mt.refresh()

    # refresh the thread buffer
    ui.current_buffer.refresh()

reply to an email with PGP inline encryption or with PGP inline signature

To automatically decrypt PGP encrypted emails or remove the PGP header and signature from PGP signed emails, the following hook can be used:

from alot.settings.const import settings
import alot.crypto as crypto
from alot import errors

def text_quote(message):
    # avoid importing a big module by using a simple heuristic to guess the
    # right encoding
    def decode(s, encodings=('ascii', 'utf8', 'latin1')):
        for encoding in encodings:
            try:
                return s.decode(encoding)
            except UnicodeDecodeError:
                pass
        return s.decode('ascii', 'ignore')
    lines = message.splitlines()
    if len(lines) == 0:
        return ""
    # delete empty lines at beginning and end (some email client insert these
    # outside of the pgp signed message...)
    if lines[0] == '' or lines[-1] == '':
        from itertools import dropwhile
        lines = list(dropwhile(lambda l: l == '', lines))
        lines = list(reversed(list(dropwhile(lambda l: l == '', reversed(lines)))))
    if len(lines) > 0 and lines[0] == '-----BEGIN PGP MESSAGE-----' \
            and lines[-1] == '-----END PGP MESSAGE-----':
        try:
            sigs, d = crypto.decrypt_verify(message.encode('utf-8'))
            message = decode(d)
        except errors.GPGProblem:
            pass
    elif len(lines) > 0 and lines[0] == '-----BEGIN PGP SIGNED MESSAGE-----' \
            and lines[-1] == '-----END PGP SIGNATURE-----':
        # gpgme does not seem to be able to extract the plain text part of a signed message
        import gnupg
        gpg = gnupg.GPG()
        d = gpg.decrypt(message.encode('utf8'))
        message = d.data.decode('utf8')
    quote_prefix = settings.get('quote_prefix')
    return "\n".join([quote_prefix + line for line in message.splitlines()])

Add X-Debbugs-No-Ack for debbugs

The debbugs bug tracking system as used by gnu.org and debian.org is controlled via email. By default messages to a bug result in an acknowledgement email. To prevent these emails, the X-Debbugs-No-Ack header has to be added to the emails to the bug server. The following hook automatically adds this header to all emails to [email protected]:

from email.utils import getaddresses
def pre_envelope_send(ui, dbm, cmd):
    e = ui.current_buffer.envelope
    found = False
    for header in ['To', 'Cc', 'Bcc']:
        for _, address in getaddresses(e.get_all(header, [])):
            if re.match(r'^\[email protected]$', address):
                found = True
                break
        if found:
            break
    if found:
        e.add("X-Debbugs-No-Ack", "kthxbye")

Getting new msg while alot is running

If you need to fetch your email and you've already configured notmuch hooks you may want to add a hook in alot in order to run notmuch new and fetch the new email when you press a key.

First you need to add some code in alot/hooks.py in order to define the function getmail

def getmail(ui=None):
    ui.notify("fetchinig email..")
    msg=subprocess.Popen("notmuch new".split(),stdout=subprocess.PIPE,stderr=subprocess.PIPE)

Then you can map a key in order to do get your emails just adding the following line in your alot/config file

[bindings]
   G = call hooks.getmail(ui)

Knowing which version/branch/commit of alot you are using

As an alot developer I run alot directly from git. In order to know which version of the code I am currently running (helpful if you switch branches) I use this hook function in my inital_command:

import alot
import os.path
import subprocess
def version_notification(ui):
    dn = os.path.dirname
    directory = dn(dn(alot.__file__))
    output = lambda *x: subprocess.check_output(x, cwd=directory).strip()
    commit = output('git', 'describe', '--tags')
    branch = output('git', 'rev-parse', '--abbrev-ref', 'HEAD')
    ui.notify('Version:    {}\nGit commit: {}\nGit branch: {}'.format(
        alot.__version__, commit, branch), timeout=10)

Open HTML emails in external browser

Useful for viewing and printing HTML emails as they are intended by the sender:

import alot
import tempfile
import webbrowser
from alot.helper import string_sanitize
from alot.helper import string_decode

# Helper method to extract the raw html part of a message. Note that it
# only extracts the first text/html part found.
def _get_raw_html(msg):
    mail = msg.get_email()

    for part in mail.walk():
        ctype = part.get_content_type()

        if ctype != "text/html":
            continue

        cd = part.get('Content-Disposition', '')

        if cd.startswith('attachment'):
            continue

        enc = part.get_content_charset() or 'utf-8'

        raw = string_decode(part.get_payload(decode=True), enc)

        return string_sanitize(raw)

    return None


# Opens HTML emails in an external browser.
# Related issue:
#  - https://github.com/pazz/alot/issues/1153
def open_in_browser(ui=None):
    ui.notify("Opening message in browser...")
    msg = ui.current_buffer.get_selected_message()

    htmlstr = _get_raw_html(msg)

    if htmlstr == None:
        ui.notify("Email has no html part")
        return

    temp = tempfile.NamedTemporaryFile(prefix="alot-",suffix=".html",
                                       delete=False)
    temp.write(htmlstr.encode("utf-8"))
    temp.flush()
    temp.close()
    webbrowser.open(temp.name)

Import GPG keys

This piece of code implements collection of keys both as ascii-armored inline text and as attached MIME parts, and allows importing them into your main keyring.

A temporary keyring is used to parse the keys found (because gpgme doesn't allow for other forms of key parsing, it seems) and a confirmation is required for each key found. Improvements are very welcome. :-)

Add the following to .config/alot/hooks.py:

import gpg
import os
import shutil
import signal
import subprocess
import tempfile

from contextlib import contextmanager
from twisted.internet import defer


BEGIN_KEY = "-----BEGIN PGP PUBLIC KEY BLOCK-----"
END_KEY = "-----END PGP PUBLIC KEY BLOCK-----"


def _get_inline_keys(content):
    if BEGIN_KEY not in content:
        return []

    keys = []
    while content:
        start = content.find(BEGIN_KEY)
        if start == -1:
            # there are no more inline keys
            break
        content = content[start:]
        end = content.find(END_KEY) + len(END_KEY)
        key = content[0:end]
        keys.append(key)
        content = content[end:]

    return keys


def _get_attached_keys(attachments):
    keys = []
    for attachment in attachments:
        content_type = attachment.get_content_type()
        if content_type == 'application/pgp-keys':
            keys.append(attachment.get_data())
    return keys


@contextmanager
def temp_gpg_context():
    tempdir = tempfile.mkdtemp()
    tempctx = gpg.Context()
    tempctx.set_engine_info(gpg.constants.PROTOCOL_OpenPGP, home_dir=tempdir)
    try:
        yield tempctx
    finally:
        # Kill any gpg-agent's that have been opened
        lookfor = 'gpg-agent --homedir {}'.format(tempdir)
        out = subprocess.check_output(['ps', 'xo', 'pid,cmd'],
                                      stderr=open('/dev/null', 'w'))
        for each in out.strip().split('\n'):
            pid, cmd = each.strip().split(' ', 1)
            if cmd.startswith(lookfor):
                os.kill(int(pid), signal.SIGKILL)
        shutil.rmtree(tempdir)


@defer.inlineCallbacks
def import_keys(ui):
    ui.notify('Looking for keys in message...')
    m = ui.current_buffer.get_selected_message()
    content = m.get_text_content()
    attachments = m.get_attachments()
    inline = _get_inline_keys(content)
    attached = _get_attached_keys(attachments)
    keys = inline + attached

    if not keys:
        ui.notify('No keys found in message.')
        return

    for keydata in keys:
        with temp_gpg_context() as tempctx:
            tempctx.op_import(keydata)
            key = [k for k in tempctx.keylist()].pop()
            fpr = key.fpr
            uids = [u.uid for u in key.uids]
        confirm = 'Found key %s with uids:' % fpr
        for uid in uids:
            confirm += '\n  %s' % uid
        confirm += '\nImport key into keyring?'
        if (yield ui.choice(confirm, select='yes')) == 'yes':
            # ***ATTENTION*** - operation in real keyring
            ctx = gpg.Context()
            ctx.op_import(keydata)
            ui.notify('Key imported: %s' % fpr)

You can add a key binding to call the hook in .config/alot/config as such:

[bindings]
  [[thread]]
    k = call hooks.import_keys(ui)

Attach GPG keys

The following hooks allow for searching for key in your default keyring in different ways and attaching them to a message.

Things to improve:

  • an alternative way to see the list of keys found and select one (or many) for attachment.
  • give the option of attaching a minimal key (i.e. using GPGME_EXPORT_MODE_MINIMAL).
  • others?
import gpg

from email.mime.base import MIMEBase
from email.encoders import encode_base64
from email.utils import parseaddr
from twisted.internet import defer

from alot.db.attachment import Attachment
from alot.settings.const import settings


#
# Attach key
#

def _key_to_mime(ctx, fpr):
    """
    Return an 'application/pgp-keys' MIME part containing an ascii-armored
    OpenPGP public key.
    """
    filename = '0x{}.pub.asc'.format(fpr)
    key = gpg.Data()
    ctx.op_export(fpr, 0, key)
    key.seek(0, 0)
    content = key.read()
    part = MIMEBase('application', 'pgp-keys')
    part.set_payload(content)
    encode_base64(part)
    part.add_header('Content-Disposition', 'attachment', filename=filename)
    return part


def _attach_key(ui, pattern):
    """
    Attach an OpenPGP public key to the current envelope.
    """
    ctx = gpg.Context()
    ctx.armor = True
    keys = _list_keys(pattern)
    for key in keys:
        part = _key_to_mime(ctx, key.fpr)
        attachment = Attachment(part)
        ui.current_buffer.envelope.attachments.append(attachment)
        ui.notify('Attached key %s' % key.fpr)
    ui.current_buffer.rebuild()


def _list_keys(pattern):
    """
    Return a list of OpenPGP keys that match the given pattern.
    """
    ctx = gpg.Context()
    ctx.armor = True
    keys = [k for k in ctx.keylist(pattern)]
    return keys


@defer.inlineCallbacks
def attach_keys(ui):
    """
    Query the user for a pattern, search the default keyring, and offer to
    attach matching keys.
    """
    pattern = yield ui.prompt('Search for key to attach')
    ui.notify('Looking for "{}" in keyring...'.format(pattern))
    keys = _list_keys(pattern)

    if not keys:
        ui.notify('No keys found.')
        return

    for key in keys:
        prompt = []
        fpr = "{}".format(key.fpr)
        prompt.append("Key 0x{}:".format(fpr))
        for uid in key.uids:
            prompt.append("  {}".format(uid.uid))
        prompt.append('Attach?')
        if (yield ui.choice('\n'.join(prompt), select='yes')) == 'yes':
            _attach_key(ui, fpr)


def attach_my_key(ui):
    """
    Attach my own OpenPGP public key to the current envelope.
    """
    sender = ui.current_buffer.envelope.get('From', "")
    address = parseaddr(sender)[1]
    acc = settings.get_account_by_address(address)
    fpr = acc.gpg_key.fpr
    return _attach_key(ui, fpr)


def attach_recipient_keys(ui):
    """
    Attach the OpenPGP public keys of all the recipients of the email.
    """
    to = ui.current_buffer.envelope.get('To', "")
    cc = ui.current_buffer.envelope.get('Cc', "")
    for recipient in to.split(',') + cc.split(','):
        address = parseaddr(recipient)[1]
        if address:
            _attach_key(ui, address)

You can add key bindings to call the hooks in .config/alot/config as such:

[bindings]
  [[envelope]]
    k = call hooks.attach_my_key(ui)
    K = call hooks.attach_keys(ui)
    'control k' = call hooks.attach_recipient_keys(ui)

Apply patches to repositories based on the notmuch tags applied

This hook allows you to bind a key to apply patches to different projects based on the notmuch tags of the email.

create a file called patch.config alongside your main alot config (usually in ~/.config/alot/patch.config), it is organized such that each section heading is a tag, and it has a single option directory which points to the source directory of that repository:

[lists/alot]
directory = ~/src/alot

Add a binding such as:

[bindings]
    [[thread]]
        a = call hooks.apply_patch(ui)
# Copyright © 2017 Dylan Baker
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import logging
import os
import subprocess

from alot.settings.utils import read_config



def _get_config():
    config_path = os.path.join(
        os.environ.get('XDG_CONFIG_HOME', os.path.join(os.environ['HOME'], '.config')),
        'alot', 'patch.config')
    return read_config(configpath=config_path)


CONFIG = _get_config()


def apply_patch(ui):
    message = ui.current_buffer.get_selected_message()
    filename = message.get_filename()

    for tag in message.get_tags():
        if tag in CONFIG:
            config = CONFIG[tag]
            break
    else:
        logging.debug('found: ' + ', '.join(message.get_tags()))
        ui.notify('No tags matched a config rule!', priority='error')
        return

    try:
        subprocess.check_output(
            ['git', '-C', os.path.expanduser(config['directory']), 'am', '-3', filename],
            stderr=subprocess.STDOUT)
    except Exception as e:
        ui.notify('Failed to apply patch. Reason:' + str(e), priority='error')
        logging.debug('git am output: ' + e.output)
    else:
        ui.notify('Patch applied.')
Clone this wiki locally