Read and Compose Email in Emacs with Notmuch
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
- Receive email through a program which synchronize emails locally from an email server.
- Read email through a program (MUA) whose the UI offers an organized & handy presentation of emails.
- Compose email in whatever editor.
- 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:
- Receive email: offlineimap.
- Read email: notmuch.el.
- Compose email: Emacs message mode.
- 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
After setting up offlineimap correctly, you can check this information by running fdupes -mr . under ~/.email/.
, 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
Note that, at the time of writing, offlineimap is in the process to port from python2 (2020-01-01 :coffin:) to python3, see OfflineIMAP/offlineimap3.
. Then copy
the minimal configuration (the path depends on your distribution).
1cp /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# ~/.offlineimaprc2[general]3accounts = Acc1_Gmail, Acc2, Acc3 # comma-separated list of accounts45[Account Acc1_Gmail]6localrepository = LocalAcc17remoterepository = RemoteAcc18postsynchook = ~/.email/postsync.sh # notmuch tagging script9utf8foldernames = yes1011[Repository LocalAcc1]12type = Maildir13localfolders = ~/.email/my-acc1@gmail.com1415[Repository RemoteAcc1]16type = Gmail17remoteuser = my-acc1@gmail.com18remotepass = password19sslcacertfile = /etc/ssl/certs/ca-certificates.crt20readonly = true # readonly if you don't want mess up with the 'unread' tag...21folderfilter = lambda foldername: foldername in ['[Gmail].All Mail']2223[Account Acc2]24... ...
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
OfflineIMAP community’s website : No, I’m not using maxconnections
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# ~/.config/systemd/user/offlineimap@.service2[Unit]3Description=Sync mail with offlineIMAP for Account %i in .offlineimaprc4Documentation=man:offlineimap(1)56[Service]7ExecStart=/usr/bin/offlineimap -a %i -u basic8Restart=always9RestartSec=601011[Install]12WantedBy=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.
1systemctl enable --user --now offlineimap@account-1.service2systemctl enable --user --now offlineimap@account-2.service3systemctl 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# ~/.notmuch-config2[database]3path=/home/firmart/.email45[user]6name=Firmin Martin7primary_email=my-acc1@gmail.com8other_email=my-acc2@gmail.com; my-acc3@gmail.com910[new]11tags=inbox;unread;12ignore=1314[search]15exclude_tags=deleted;1617[maildir]18synchronize_flags=true
Expose highly active addresses
The following command lists email-senders address sorted by decreasing amounts of emails sent4
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.
.
1notmuch show --format=json --body=false --entire-thread=false "*"2| jq '.[] | .[] | .[0].headers.From'3| 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:
- I don’t want to see them at all and they’re harmful => spam
- I don’t want to see them at all => blacklisted
- I want to see them but it doesn’t matter when => move out inbox
- It’s important! => keep them in the inbox and tag them more
1#!/usr/bin/env bash2# ~/.email/postsync.sh34# tag_new <tags> <search-term>5function tag_new { notmuch tag $1 -- tag:inbox and $2; }67# blacklist <search-term>8function blacklist { tag_new "-inbox -unread +deleted" $1; }910# spam <search-term>11function spam { tag_new "-inbox -unread +spam +deleted" $1; }1213# security <search-term>14function security { tag_new "-inbox +Security" $1; }1516# update : let notmuch process new mails17notmuch new1819# blacklisting20notmuch tag -inbox -- tag:deleted and tag:inbox21blacklist "from:/.*@.*[.]pinterest[.]com/"22blacklist "from:/.*@linkedin[.]com/"23blacklist "from:/.*@quora[.]com/"24blacklist "from:noreply@medium.com"25blacklist "from:noreply@youtube.com"26## this list continue with 100+ addresses ...2728# ... and spams29# `+spam' can't be found at all in notmuch if `exclude_tags=deleted;spam;'30# is set in the [search] section of `.notmuch-config'.31spam "from:esf@cnnsimail.com"32# ...3334# Family first35tag_new "+family" "from:dad@gmail.com or from:mom@gmail.com"3637# Friends38# ...3940# Co-workers41# ...4243# Mailing list44tag_new "-inbox +CoqClub" "to:coq-club@inria.fr or [Coq-Club]"4546# Newsletter47tag_new "-inbox +SE.newsletter" "from:do-not-reply@stackoverflow.email"4849# Universities50# ...5152# Security (accounts/verification code/email confirmation/... etc.)53security "from:no-reply@accounts.google.com or accounts-noreply@google.com"54security "from:account-security-noreply@account.microsoft.com"5556# and more ...5758# From me59tag_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(define-key notmuch-show-mode-map "S"2(lambda ()3"delete message and move on"4(notmuch-show-tag '("+deleted" "-unread"))5(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
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.
, 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(use-package smtpmail-multi2:ensure t3:config4(setq smtpmail-multi-accounts5'((host . ("firmin.martin@host.fr" "smtp.host.fr" 587 "firmin.martin@host.fr" nil nil nil nil))6(gmail-main . ("firmin.martin@gmail.com" "smtp.gmail.com" 587 "firmin.martin@gmail.com" nil nil nil nil))))78(setq smtpmail-multi-associations9'(("firmin.martin@host.fr" host)10("firmin.martin@gmail.com" gmail-main)))1112(setq smtpmail-multi-default-account 'gmail-main)13(setq message-send-mail-function 'smtpmail-multi-send-it)1415(setq smtpmail-debug-info t)16(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.
1machine smtp.host.fr login firmin.martin port 587 password abc1232machine 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(when (>= emacs-major-version 25)2(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:
-
Create a password for your email account.
1pass insert email/myaccount -
Create a python function that retrieves the password (in
~/.offlineimap/pass.pyfor instance).1#! /usr/bin/env python32from subprocess import check_output34def get_pass(account):5return check_output("pass email/" + account, shell=True).splitlines()[0] -
In
.offlineimaprc, under the general section, indicate the python file1[general]2# ...3pythonfile = ~/.offlineimap/pass.pyand replace each
remotepass = passwordby the next one.1remotepasseval = 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(use-package auth-source-pass2:ensure t3:config4(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
1machine smtp.host.fr login firmin.martin port 587 password abc123
corresponds to ~/.password-store/email/smtp.host.fr.gpg
1abc1232user: firmin.martin3host: smtp.host.fr4port: 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--default-cache-ttl n2Set the time a cache entry is valid to n seconds. The default is 6003seconds. Each time a cache entry is accessed, the entry’s timer is reset. To set4an entry’s maximum lifetime, use max-cache-ttl. Note that a cached passphrase5may not be evicted immediately from memory if no client requests a cache6operation. This is due to an internal housekeeping function which is only run7every few seconds.89--max-cache-ttl n10Set the maximum time a cache entry is valid to n seconds. After this time a11cache entry will be expired even if it has been accessed recently or has12been 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
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.
1max-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.shwhen necessary (usually to blacklist some addresses). -
Changing password. Modify adequately
~/.offlineimaprcand.authinfo, or if you usepassas above, update the passwords withpass 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: updateaccounts, add one more account section plus
associated local/remote sections;
- update
.notmuch-config: updateprimary_emailorother_email; - update
smtpmail-multi-accountsandsmtpmail-multi-associationsif that account may be used to write email; - update credentials with
pass; - run
systemctl enable --user --now offlineimap@ACCOUNT-NAME.service; - (optional) update
postsync.shto tag the email written by yourself.
- update
Footnotes
-
After setting up
offlineimapcorrectly, you can check this information by runningfdupes -mr .under~/.email/. ↩ -
Note that, at the time of writing,
offlineimapis in the process to port frompython2(2020-01-01 :coffin:) topython3, see OfflineIMAP/offlineimap3. ↩ -
OfflineIMAP community’s website : No, I’m not using maxconnections ↩
-
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. ↩ -
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. ↩
-
If you retrieve your emails at a frequency lower than every 10 minutes, then you should also assign the same value of
max-cache-ttltodefault-cache-ttl. ↩