Customizing The Emacs Email Experience With Mu4e

12 min read Original article ↗

You all knew this was coming. After thinking about my email workflow I had to put it to practice. The grand plan was to force myself to learn more about Emacs by doing email in it with the added advantage of freeing up Mac Mail to manage my Exchange work emails there. Anything is better than staring at that dreaded Outlook web interface.

There are tons of cool blog posts out there about mbsync, mu, and mu4e configuration—this one’s mine. Most focus on how to set up mbsync which is the CLI tool that syncs your IMAP account with a local folder for mu to index. The process is fairly straightforward: the only tricky thing to do is use macOS’s password keyring to store the IMAP password and export a copy of the certificates for the handshake:

IMAPAccount brainbaking
Host imap.myserver.org
User wouter@myserver.org
PassCmd "security find-generic-password -s mu4e-brainbaking-mailbox -a wouter@brainbaking.com -w"
Port 993
TLSType IMAPS
AuthMechs Login
CertificateFile ~/Databases/maildir/certificates/root-certificates.pem

Instead, I’d like to focus on mu4e configuration, as most of my sweat originated from that direction. You can find the full config at my “bakemacs” Codeberg repository. I customized the hell out of it.

Vertical Layout

First and foremost, I hate the default UI of mu4e. Splitting windows horizontally when opening an email just feels like a giant amount of wasted space. Any other sane email client splits vertically, usually in the popular three-column mode. The first column, a quick jump to your folders, isn’t needed thanks to the shortcuts.

Changing the split config is very easy: (setq mu4e-split-view 'vertical). Fiddle with mu4e-headers-visible-columns to get that percentage header/view just right (mine’s at 40):

But then the first buffer becomes completely useless because mu4e’s header columns are sorted in a weird way. The from and subject columns are last which will be covered by the mail you just opened. Quickly scrolling through mails with n (next) and p (previous) loses its meaning. But mixing that up isn’t that easy as the last column with a width of nil is the only one that can take up the remaining room. Additionally, since I use consult-mu, I want the headers to be consistent.

(defun bb/mu4e-header-layout (&optional for-consult)
  "Calculate header widths based on current frame width.
If FOR-CONSULT is non-nil, use :date instead of :human-date."
  (let* ((width (frame-width))
         (fixed-space (+ 20 12 12 6))
         (subject-width (max 20 (- width fixed-space 10)))
         (date-field (if for-consult :date :human-date)))
    `((:from       . 20)
      (:subject    . ,subject-width)
      (:flags      . 6)
      (:short-maildir . 12)
	  (,date-field . 12))))

Wait a minute, what’s :short-maildir? That doesn’t exist! Well, it does now:

;; unclutter that :maildir column please
  (add-to-list 'mu4e-header-info-custom
               '(:short-maildir .
								(:name "Short Maildir"
								 :shortname "Folder"
								 :help "Maildir without account prefix"
								 :function (lambda (msg)
											 (let ((maildir (mu4e-message-field msg :maildir)))
											   (if maildir
												   (replace-regexp-in-string "^/?brainbaking/?" "" maildir)
												 ""))))))

No wonder the :maildir column isn’t used by default. Then, wire the header layout function to both mu4e-headers-fields and consult-mu-headers-fields. The result:

mu4e:view with a mu4e:headers buffer to the left.

To discourage Emacs from opening the HTML version first in case both MIME parts are there just like in the screenshot, set mm-discouraged-alternatives '("text/html" "text/richtext"). If you receive a lot of HTML email with weird CSS colors, this might be handy too:

(defun bb/mu4e-view-force-clean-html ()
  "Render HTML using current theme colors by disabling SHR colors."
  (interactive)
  (require 'shr)
  (let ((shr-use-colors nil))
    (mu4e-view-refresh))
  (message "HTML colors stripped."))

Threading Niceties

Mu4e feels like a classic eighties text-based terminal app. No wonder conversation mode doesn’t exist—but it makes up for that with the shortcuts (once you’re familiar with them) and the threading view options it provides.

Yet my bb/mu4e-header-layout completely screwed up that because the last column isn’t the “flexible” column anymore. Whoops. Most other blog posts seem to prefer horizontal splits as well. Digging into mu4e-thread.el, I discover a way to simply overwrite the logic:

  (with-eval-after-load 'mu4e-thread
	(defun mu4e-thread-fold-info (count unread)
      "A compact, single-line replacement for folded thread info fixing custom col logic."
      (let ((msg (format "\t[+%d%s messages in thread]\n" count (if (> unread 0) (format ", %d!" unread) ""))))
		(propertize msg 'face 'mu4e-thread-fold-face))))

There, better. How about we add a quick way to fold and unfold all these conversations?

(defun bb/mu4e-toggle-thread-folding ()
  "Toggle between folding and unfolding all threads."
  (interactive)
  (if mu4e-thread--fold-status
      (mu4e-thread-unfold-all)
	(mu4e-thread-fold-all)))

(defun bb/mu4e-toggle-thread-related ()
  "Toggle mu4e-headers-include-related and refresh the view."
  (interactive)
  (setq mu4e-headers-include-related (not mu4e-headers-include-related))
  (mu4e-headers-rerun-search))

include-related integrates your sent mails into the thread just like a conversation but quickly turns the view into a mess, hence the toggle. I bound these to z and Z. The result:

Showcasing the threading and related threading toggles in mu4e:view mode.

The decent column colours come from the package mu4e-column-faces. The flags can be souped up with fancy variants by setting mu4e-use-fancy-chars and pairing simple with fancy char (e.g. mu4e-headers-attach-mark '("a" . "📎")). I prefer using nerd icons like everywhere else but haven’t yet figured out how to do so.

Syncing, Spam, Rules

You can simply instruct mu4e to use an external syncing tool by setting mu4e-get-mail-command to "mbsync -a". I used to hack it with the value "true" and then add an Elisp hook to execute the shell command myself in order to jam in bogofilter as a spam filter but that screws up the async fetch logic. The problems don’t stop with spam filtering: I also want to apply some simple rules that automatically move incoming mails to certain IMAP folders. Mu4e doesn’t work like that, you’re supposed to use labels and leave things as is, but I’d rather not.

With some help from my friend Gemini to identify the right functions, I came up with this:

(defun bb/mu4e-filter-inbox ()
  "Move files manually and strip UIDs with a silent Bogofilter check."
  (interactive)
  (let* ((match-count 0)
         (base-dir (mu4e-root-maildir))
         (mu-find-cmd (format "mu find maildir:%s --format=plain --fields=l" mu4e-inbox-folder))
         (paths (split-string (shell-command-to-string mu-find-cmd) "\n" t)))
    (dolist (old-path paths)
	  ;; bogofilter output: 0 = spam, 1 = ham, 2 = unsure.
      (let* ((is-spam (= 0 (call-process "bogofilter" nil nil nil "<" old-path)))
             (target-dir (if is-spam "/brainbaking/Junk/new"
                           (bb/mu4e--match-rule old-path mu4e-inbox-rules))))
        (when target-dir
          (let* ((filename (file-name-nondirectory old-path))
                 (clean-name (replace-regexp-in-string ",U=[0-9]+" "" filename))
                 (new-path (concat base-dir (expand-file-name clean-name target-dir))))
            (rename-file old-path new-path t)
            (setq match-count (1+ match-count))))))
    
    (when (> match-count 0)
      (progn
        (mu4e-update-index)
        (message "Filtered %d messages." match-count)))))

That’s hooked into mu4e-index-updated-hook. What does this thing do?

  1. Find all email paths in the inbox folder (mu4e-inbox-folder is a custom var I made up) using mu find. I failed to find something working that mu4e provided.
  2. For each path, ask bogofilter if this is spam. If yes, move to /Junk/new. If no, check the rules to see where it should end up in.
  3. Move with rename-file, but strip the mu suffixes that already gave it an ID as this otherwise confuses mbsync/mu because we moved the file ourselves.
  4. Re-index if anything happened to keep things in sync.

Matching for rules is fairly straightforward:

(defun bb/mu4e--match-rule (file rules)
  "Search the first 10000 chars of FILE for RULES.
Return the target folder or nil."
  (with-temp-buffer
    ;; a peu pres the header area (2000 was too narrow) to avoid dumping the entire mail in there
    (insert-file-contents file nil 0 10000)
    (let ((target nil))
      (dolist (rule rules target)
        (goto-char (point-min))
        (when (re-search-forward (car rule) nil t)
          (setq target (cdr rule)))))))
		  
(setq   mu4e-inbox-rules '(("^From:.*Limited Run Games" . "/brainbaking/Mailinglists/new")
						   ("^From:.*SBS" . "/brainbaking/Mailinglists/cur")))

The function inserts the first 10k chars of the email file itself into a temp buffer and uses regex to match the rules. A few caveats: From: occurs more than once in a raw email file, search for the beginning of a line. Also, the first 2k chars wasn’t enough, some headers contain a lot of junk. You could just as well dump everything in there but the limit is there just in case.

But what if we move an email to the junk folder ourselves—shouldn’t we train bogofilter to identify future mails like that as spam? Ah yes:

(defun bb/mu4e-train-on-move (docid msg target)
  "Train bogofilter when moving to spam/inbox."
  (let* ((rel-path (mu4e-message-field msg :path))
		 (abs-path (expand-file-name rel-path (mu4e-root-maildir))))
	(cond
	 ;; Moving TO Junk
	 ((string= target mu4e-spam-folder)
      (progn
		(shell-command (format "bogofilter -s < %s" (shell-quote-argument abs-path)))
		(message "Bogofilter: Trained SPAM (%s)" abs-path)))
	 ;; Moving TO Inbox
	 ((string= target mu4e-inbox-folder)
	  (shell-command (format "bogofilter -n < %s" (shell-quote-argument abs-path)))
	  (message "Bogofilter: Trained HAM (%s)" abs-path)))))

;; hook this thing in like this:
  (with-eval-after-load 'mu4e
	(let ((move-mark (alist-get 'move mu4e-marks)))
      (setf (alist-get 'move mu4e-marks)
			(plist-put move-mark :action
                       (lambda (docid msg target) ;; train and do the original server move
						 (bb/mu4e-train-on-move docid msg target)
						 (mu4e--server-move docid target))))))

I don’t know if the hook hack is the right thing to do but this works.

Fixing Annoyances

More annoyances? You’d be starting to wonder why use mu4e at all, right? Because we can and because it’s Lisp!

When marking mails for actions such as deleting and moving, after pressing x to execute all marks you still have to confirm with y or n. I hate that: I want x and that’s it. Another hack to the rescue:

(defun bb/mu4e-mark-execute ()
  "Execute mu4e mark all without asking for confirmation."
  (interactive)
  (mu4e-mark-execute-all t))

;; hook this thing in like this:
  :bind (:map mu4e-main-mode-map
		 ("u" . mu4e-update-mail-and-index)
		 :map mu4e-headers-mode-map
         ("z" . bb/mu4e-toggle-thread-folding)
         ("Z" . bb/mu4e-toggle-thread-related)
		 ("s-f" . #'consult-mu)
		 ("x" . bb/mu4e-mark-execute)
		 :map mu4e-view-mode-map
		 ("x" . bb/mu4e-mark-execute))

The :bind solves another annoyance: updating in main mode is bound to the U key but I don’t need that stinkin’ uppercase there. Also, since s-f is my consult-line anywhere else because I’m coming from a more traditional editor, I have it pop up consult-mu here instead.

While debugging the spam filter function I occasionally required the full path of the open mail. Here’s a handy function that adds it to your kill ring (that’s Emacs l33t speak for “clipboard history”):

(defun bb/mu4e-copy-message-path ()
  "Copy the path of the current message to the `kill-ring`."
  (interactive)
  (let ((path (mu4e-message-field (mu4e-message-at-point) :path)))
    (kill-new path)
    (message "Copied: %s" path)))

We’re almost there. Yesterday I had the luminous idea to integrate Mac Contacts with mu4e. By default, when you enable auto-completion in compose mode, TAB fetches data from mu’s indexes. That means you’ll see email addresses from folks you’ve already exchanged mails with. But I might have contacts saved (wired to our own CardDav server) where that’s not the case. I discovered that this functionality is actually built into Emacs with eudcb-macos-contacts. Except that that didn’t work.

Well, it didn’t at first because I had Emacs running as a daemon using launchctl which is very strictly sandboxed and blocks any access to Contacts even though I explicitly approved it in the security settings. Now I run it as a simple login items startup shell script meaning it runs under my account.

And then it still didn’t run smoothly: the autocomplete took 4 seconds to load because eudcb is ridiculously slow. So I went the other route and tried contacts-cli, a small tool that fetches info from Contacts leveraging Swift’s native Mac-compliant capabilities. And that didn’t work either because I couldn’t get the tool to run. So I rolled my own, or rather, let Gemini do most of the rolling, as I don’t know anything about Swift let alone the Mac-specific interfaces. It came up with a small script that I simply embedded into Elisp as a string:

(defun bb/mac-contacts-query (str)
  "Query macOS contacts for STR using a native Swift snippet."
  (unless (or (null str) (string-empty-p (string-trim str)))
    (let* ((swift-code (format "
import Contacts
let store = CNContactStore()
let keys = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactEmailAddressesKey] as [CNKeyDescriptor]
let predicate = CNContact.predicateForContacts(matchingName: \"%s\")
if let contacts = try? store.unifiedContacts(matching: predicate, keysToFetch: keys) {
    for c in contacts {
        for e in c.emailAddresses {
            let name = \"\\(c.givenName) \\(c.familyName)\".trimmingCharacters(in: .whitespaces)
            print(\"\\(name) <\\(e.value)>\")
        }
    }
}" str))
           (cmd (format "echo %s | swift -" (shell-quote-argument swift-code)))
           (results (shell-command-to-string cmd)))
      (unless (string-empty-p (string-trim results))
        (delete-dups (split-string (string-trim results) "\n" t))))))

The output of (bb/mac-contacts-query "wouter") then becomes ("Wouter Groeneveld <emailaddress@domain.be>"). Cool! But how do we hook this into the existing mu4e autocomplete that already serves mu’s indexed email addresses? Use Cape’s Super-Capf that merges stuff into a giant completion at point function:

(defun bb/mac-contacts-capf ()
  "Cape-compatible CAPF for macOS contacts via Swift."
  (let ((bounds (bounds-of-thing-at-point 'symbol)))
    ;; Check if we are actually in a header field (To, Cc, Bcc)
    (when (and bounds (save-excursion
                        (beginning-of-line)
                        (looking-at "^\\(To\\|Cc\\|Bcc\\):")))
      (list (car bounds) (cdr bounds)
            (bb/mac-contacts-query (buffer-substring-no-properties (car bounds) (cdr bounds)))
            :exclusive 'no
            :company-kind (lambda (_) 'email)
            :annotation-function (lambda (_) " [Mac]")))))

(defun bb/mu4e-compose-setup ()
  "Setup mu4e compose mode (proper corfu, spell et al stuff)."
  (setq-local corfu-auto nil
			  completion-cycle-threshold nil
			  completion-at-point-functions (list
											 (cape-capf-super
											  (cape-capf-properties #'mu4e-complete-contact :company-kind (lambda (_) 'email))
											  #'bb/mac-contacts-capf)
											 #'message-completion-function
											 #'ispell-completion-at-point))

  (auto-fill-mode -1)
  (toggle-input-method)
  (corfu-mode 1))
(add-hook 'mu4e-compose-mode-hook #'bb/mu4e-compose-setup)

There, more annoyances fixed by disabling auto fill mode and telling Corfu to stay put until I press TAB myself. The keen Elisper will notice that we also wrapped the default mu4e-complete-contact to be able to inject a :company-kind lambda. This adds a nice icon to keep things consistent. Yes, you’re right, 'email is a symbol that doesn’t exist in nerd-icons—just define it yourself with '(email :style "cod" :icon "mail" :face font-lock-variable-name-face). The result:

Autocompleting email addresses in the To: field in mu4e:compose mode.

Note the two test emails appearing with [Mac] suffixes: these come from Mac Contacts, while the first email address is a bogus one I emailed to in order to showcase the capf merge. The first time this triggers it’s still a bit slow because of the Swift interpreter. I guess I can look into compiling that somehow? For now, I hope not to mess too much with the config anymore and to actually, you know, use it? Ah, the Emacs curse…

software   emacs  email