This page looks best with JavaScript enabled

Read and Compose Email in Emacs with Notmuch

 ·   ·  ☕ 11 min read  ·  ✍️  Firmin Martin

It has been 18 months that I read & write my emails in Emacs. No need to say I have enjoyed the mouse-free experience brought by Emacs. Recently, I had to keep track a new email account. So I came across my old note written back then which I enhanced in this post. I made lots of updates subsequently including password management through pass, multi-accounts support etc. to make it as complete as possible.

Introduction

A full back-and-forth cycle of email consists to

  1. Receive email through a program which synchronize emails locally from an email server.
  2. Read email through a program (MUA) whose the UI offers an organized & handy presentation of emails.
  3. Compose email in whatever editor.
  4. Send email with a mail transfer agent or an interface of it.

I made the following choices which I will detail the configuration throughout this post:

  1. Receive email: offlineimap.
  2. Read email: notmuch.el.
  3. Compose email: Emacs message mode.
  4. Send email: smtpmail-multi to send email through multiple SMTP servers.

As you may have seen, except the reception of email, the remaining can be done within Emacs.

Receiving email

As stated above, we use offlineimap to fetch emails from potentially multiple mailbox. But most importantly, we store all emails locally for two purposes: 1. to be able to read emails offline, 2. to not mess up tags synchronization which may cause data loss. You may think that your huge mailbox would take a tremendous place in your disk. Well, I can say that if you pay attention to keep only one copy of your emails1, it should not take much. For instance, I have 2.6k emails taking 460MB of the disk.

Configure offlineimap

Install offlineimap with your favorite package manager2. Then copy the minimal configuration (the path depends on your distribution).

1
cp /usr/share/doc/offlineimap/examples/offlineimap.conf.minimal ~/.offlineimaprc

Here is the relevant part of my configuration (~/.offlineimaprc) for reference. See the documentation in /usr/share/doc/offlineimap/examples/offlineimap.conf or Archwiki for more information. Note the postsynchook option at the account level: it’s an email tagging script which is run as soon as new email arrives. We will come soon to its content in this section. Remember what I said regarding the space taken by locally stored email? Well, they remain tiny provided that they are not duplicated elsewhere. That’s not always the case, for instance Gmail may store an email in the folder [Gmail].Important beside [Gmail].All Mail. You may consider to filter out the extra folders you don’t want as below with a python’s lambda expression or a function (see the documentation).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# ~/.offlineimaprc
[general]
accounts = Acc1_Gmail, Acc2, Acc3 # comma-separated list of accounts

[Account Acc1_Gmail]
localrepository = LocalAcc1
remoterepository = RemoteAcc1
postsynchook = ~/.email/postsync.sh # notmuch tagging script
utf8foldernames = yes

[Repository LocalAcc1]
type = Maildir
localfolders = ~/.email/my-acc1@gmail.com

[Repository RemoteAcc1]
type = Gmail
remoteuser = my-acc1@gmail.com
remotepass = password
sslcacertfile = /etc/ssl/certs/ca-certificates.crt
readonly = true # readonly if you don't want mess up with the 'unread' tag...
folderfilter = lambda foldername: foldername in ['[Gmail].All Mail']

[Account Acc2]
...  ...

Launch offlineimap automatically at boot

You would certainly want to launch automatically offlineimap at boot. This can be done with systemd. In my case, I have three accounts, it’s advised 3 to create three separated systemd services and set maxsyncaccounts = 1 in ~/.offlineimaprc as we have done above.

Instead of write three different system service files, I write the following template unit file where the variable %i will match later with an account name in .offlineimaprc. (Note that you should avoid “@” in the account name since systemd gives it a precise meaning).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ~/.config/systemd/user/offlineimap@.service
[Unit]
Description=Sync mail with offlineIMAP for Account %i in .offlineimaprc
Documentation=man:offlineimap(1)

[Service]
ExecStart=/usr/bin/offlineimap -a %i -u basic
Restart=always
RestartSec=60

[Install]
WantedBy=default.target

Then run systemctl daemon-reload to load the new service file. The following commands enable the auto-start on boot and launch the service right now.

1
2
3
systemctl enable --user --now offlineimap@account-1.service
systemctl enable --user --now offlineimap@account-2.service
systemctl enable --user --now offlineimap@account-3.service

Note that account-* is the account name appeared in each [ Account XXX ] section. If everything goes well, offlineimap will sync emails on the next boot automatically.

Auto-tagging with notmuch

The next thing to do is email auto-tagging, without this feature your mailbox will be a nightmare. Again, install notmuch with your favorite package manager. We will write the script aforementioned so that email be filtered as soon as they are synced locally.

Configurate notmuch

Before starting to use notmuch, you must configure it. In particular, you have to set the database path, your email accounts which appeared in ~/.offlineimaprc, tagging rule for incoming email and tag to exclude by default when searching.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# ~/.notmuch-config
[database]
path=/home/firmart/.email

[user]
name=Firmin Martin
primary_email=my-acc1@gmail.com
other_email=my-acc2@gmail.com; my-acc3@gmail.com

[new]
tags=inbox;unread;
ignore=

[search]
exclude_tags=deleted;

[maildir]
synchronize_flags=true

Expose highly active addresses

The following command lists email-senders address sorted by decreasing amounts of emails sent4.

1
2
3
notmuch show --format=json --body=false --entire-thread=false "*"
| jq '.[] | .[] | .[0].headers.From'
| sort | uniq -c | sort -n

Replace From by To to expose highly active mailing list.

The snippet above help us to find out the best contributors of our inbox to tag them properly.

Auto-tagging script

Here is my little shell script ~/.email/postsync.sh which is run once offlineimap finished to sync my emails. You might want to take a look at notmuch help search-terms to understand the syntax of tagging commands.

I identify several visibility categories of emails:

  1. I don’t want to see them at all and they’re harmful => spam
  2. I don’t want to see them at all => blacklisted
  3. I want to see them but it doesn’t matter when => move out inbox
  4. It’s important! => keep them in the inbox and tag them more
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#!/usr/bin/env bash
# ~/.email/postsync.sh

# tag_new <tags> <search-term>
function tag_new { notmuch tag $1 -- tag:inbox and $2; }

# blacklist <search-term>
function blacklist { tag_new "-inbox -unread +deleted" $1; }

# spam <search-term>
function spam { tag_new "-inbox -unread +spam +deleted" $1; }

# security <search-term>
function security { tag_new "-inbox +Security" $1; }

# update : let notmuch process new mails
notmuch new

# blacklisting
notmuch tag -inbox -- tag:deleted and tag:inbox
blacklist "from:/.*@.*[.]pinterest[.]com/"
blacklist "from:/.*@linkedin[.]com/"
blacklist "from:/.*@quora[.]com/"
blacklist "from:noreply@medium.com"
blacklist "from:noreply@youtube.com"
## this list continue with 100+ addresses ...

# ... and spams
# `+spam' can't be found at all in notmuch if `exclude_tags=deleted;spam;'
# is set in the [search] section of `.notmuch-config'.
spam "from:esf@cnnsimail.com"
# ...

# Family first
tag_new "+family" "from:dad@gmail.com or from:mom@gmail.com"

# Friends
# ...

# Co-workers
# ...

# Mailing list
tag_new "-inbox +CoqClub" "to:coq-club@inria.fr or [Coq-Club]"

# Newsletter
tag_new "-inbox +SE.newsletter" "from:do-not-reply@stackoverflow.email"

# Universities
# ...

# Security (accounts/verification code/email confirmation/... etc.)
security "from:no-reply@accounts.google.com or accounts-noreply@google.com"
security "from:account-security-noreply@account.microsoft.com"

# and more ...

# From me
tag_new "-inbox -unread +FromMe" "from:my-main-gmail@gmail.com or from:univ-account@my-univ.fr or from:my-second@gmail.com"

Reading mail

notmuch.el

Follow the instructions given on the official website.

Key-bindings

The key-bindings I use are from evil-collection. They are quite different from the default ones. You can define new keybindings for different notmuch views (tree, show, hello, search, message) as below, but usually I rarely tag manually an email (except flagging important one). Instead, I add a new tagging rule as depicted above.

1
2
3
4
5
(define-key notmuch-show-mode-map "S"
  (lambda ()
    "delete message and move on"
    (notmuch-show-tag '("+deleted" "-unread"))
    (notmuch-show-next-open-message-or-pop)))

Compose email

Simply press C-x m (compose-mail) in Emacs to compose an email to send. Normally, the From:=/=To: fields can be autocompleted.

Send email

Unless your local system is configured for sending email using sendmail, you may want to access a remote SMTP server.

SMTP configuration

Below is a fragment of my SMTP setup. You should acquire this information from the host (Gmail5, your institution, your company etc.). Using smtpmail is not enough to sending email with different accounts. Fortunately, the package smtpmail-multi made the task easier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
(use-package smtpmail-multi
  :ensure t
  :config
    (setq smtpmail-multi-accounts
    '((host . ("firmin.martin@host.fr" "smtp.host.fr" 587 "firmin.martin@host.fr" nil nil nil nil))
    (gmail-main . ("firmin.martin@gmail.com" "smtp.gmail.com" 587 "firmin.martin@gmail.com" nil nil nil nil))))

    (setq smtpmail-multi-associations
    '(("firmin.martin@host.fr" host)
    ("firmin.martin@gmail.com" gmail-main)))

    (setq smtpmail-multi-default-account 'gmail-main)
    (setq message-send-mail-function 'smtpmail-multi-send-it)

    (setq smtpmail-debug-info t)
    (setq smtpmail-debug-verbose t))

Then you have to put your credentials somewhere. Such places are designated by the variable auth-sources which defaults to ("~/.authinfo" "~/.authinfo.gpg" "~/.netrc").

For instance, put the following in ~/.authinfo.

1
2
machine smtp.host.fr login firmin.martin port 587 password abc123
machine smtp.gmail.com login firmin.martin port 587 password abc123

Patch: Fully-Qualified Domain Name (FQDN)

You may encounter issue regarding the FQDN when sending email. I have the following patch in my configuration coming from here.

1
2
(when (>= emacs-major-version 25)
  (setq smtpmail-local-domain (car (split-string (shell-command-to-string "hostname -f")))))

Bonus: passwords encryption with pass

You may have seen a security hole which would hopefully make you uncomfortable: we have written credentials in plain text. Let’s fix it. I assume in the following that the reader has already setup gpg (2.1+) and pass.

Remember, we have stored passwords in ~/.offlineimaprc to pull emails locally with offlineimap and in ~/.authinfo so that Emacs is able to send email.

~/.offlineimaprc

Quoting ArchWiki:

  1. Create a password for your email account.

    1
    
    pass insert email/myaccount
    
  2. Create a python function that retrieves the password (in ~/.offlineimap/pass.py for instance).

    1
    2
    3
    4
    5
    
    #! /usr/bin/env python3
    from subprocess import check_output
    
    def get_pass(account):
        return check_output("pass email/" + account, shell=True).splitlines()[0]
    
  3. In .offlineimaprc, under the general section, indicate the python file

    1
    2
    3
    
    [general]
    # ...
    pythonfile = ~/.offlineimap/pass.py
    

    and replace each remotepass = password by the next one.

    1
    
    remotepasseval = get_pass("myaccount")
    

auth-source-pass

To make Emacs read credentials through pass, we use the package auth-source-pass which exactly do the job for us. The configuration is simple.

1
2
3
4
(use-package auth-source-pass
  :ensure t
  :config
  (auth-source-pass-enable))

You should create a <smtp host>.gpg with pass --edit email/<smtp host> for each smtp server. For instance, the entry of .authinfo

1
machine smtp.host.fr login firmin.martin port 587 password abc123

corresponds to ~/.password-store/email/smtp.host.fr.gpg

1
2
3
4
abc123
user: firmin.martin
host: smtp.host.fr
port: 587

Cache gpg passphrase

By now, offlineimap and Emacs will retrieve your passwords through pass. Great! But, if you setup gpg without extra configuration, you will be prompted the passphrase every two hours. Why? The answer lies in the gpg agent options default-cache-ttl and --max-cache-ttl. The documentation says

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
--default-cache-ttl n
    Set the time a cache entry is valid to n seconds. The default is 600
    seconds. Each time a cache entry is accessed, the entry’s timer is reset. To set
    an entry’s maximum lifetime, use max-cache-ttl. Note that a cached passphrase
    may not be evicted immediately from memory if no client requests a cache
    operation. This is due to an internal housekeeping function which is only run
    every few seconds.

--max-cache-ttl n
    Set the maximum time a cache entry is valid to n seconds. After this time a
    cache entry will be expired even if it has been accessed recently or has
    been set using gpg-preset-passphrase. The default is 2 hours (7200 seconds).

That is, by default, the passphrase is cached 10 minutes and can be extended each time it is accessed up to 2 hours. As we set RestartSec=60 in ~/.config/systemd/user/offlineimap@.service, it ensures that we reach the maximum cache time. To increase the cache time permanently to one day, add the line below in ~/.gnupg/gpg-agent.conf.6

1
max-cache-ttl 86400

You should restart gpg-agent to see the effect (gpg -K should be enough). At this point, not only are your passwords secure to some extent, but no one can see and write emails on your behalf after one day without enter the passphrase.

Addendum: general workflow

I summarize below how one maintains this email workflow.

  • Tagging emails. Update ~/.email/postsync.sh when necessary (usually to blacklist some addresses).

  • Changing password. Modify adequately ~/.offlineimaprc and .authinfo, or if you use pass as above, update the passwords with pass edit email/<account>. Beware, if you only change your password remotely, you won’t be able to receive and (possibly) write any email.

  • Adding new email. Each time you want to add a new email account in you workflow, you should

    • update .offlineimaprc: update accounts, add one more account section plus

    associated local/remote sections;

    • update .notmuch-config: update primary_email or other_email;
    • update smtpmail-multi-accounts and smtpmail-multi-associations if that account may be used to write email;
    • update credentials with pass;
    • run systemctl enable --user --now offlineimap@ACCOUNT-NAME.service;
    • (optional) update postsync.sh to tag the email written by yourself.

  1. After setting up offlineimap correctly, you can check this information by running fdupes -mr . under ~/.email/↩︎

  2. Note that, at the time of writing, offlineimap is in the process to port from python2 (2020-01-01 ⚰️) to python3, see OfflineIMAP/offlineimap3↩︎

  3. OfflineIMAP community’s website : No, I’m not using maxconnections ↩︎

  4. Technically you can aggregate all duplicate email addresses with jq, but you would have to handle the case, comma-separated addresses, and the "First Last <first.last@gmail.com>" notation. It’s merely an example after all. ↩︎

  5. If your Google account has 2-step verification activated, you will likely have to create and use an app password instead of your regular password. ↩︎

  6. If you retrieve your emails at a frequency lower than every 10 minutes, then you should also assign the same value of max-cache-ttl to default-cache-ttl↩︎


Firmin Martin
WRITTEN BY
Firmin Martin

What's on this Page