programming (and other) musings
11 Mar 2021

notmuch queries via consult

This package provides notmuch queries in emacs using consult. It offers interactive functions to launch search processes using the notmuch executable and present their results in a completion minibuffer and, after selection of a candidate, single message and tree views.

The package's elisp code is tangled from this literate program.

Dependencies and installation

We depend on the emacs packages consult and notmuch:

(require 'consult)
(require 'notmuch)

Both, as well as this package, are available in MELPA, so that's the easiest way to install consult-notmuch. You can also simply tangle this document and put the resulting consult-notmuch.el file in your load path (provided you have its dependencies in your load path too), or obtain it from its git repository.

Usage

Our objective is to use consult to perform notmuch queries, and show their results in the completions minibuffer and, upon selection, on a dedicated notmuch buffer. Notmuch knows how to show either single messages or full threads (or trees), so our public interface is going to consist of two autoloaded commands:

;;;###autoload
(defun consult-notmuch (&optional initial)
  "Search for your email in notmuch, showing single messages.
If given, use INITIAL as the starting point of the query."
  (interactive)
  (consult-notmuch--show (consult-notmuch--search initial)))

;;;###autoload
(defun consult-notmuch-tree (&optional initial)
  "Search for your email in notmuch, showing full candidate tree.
If given, use INITIAL as the starting point of the query."
  (interactive)
  (consult-notmuch--tree (consult-notmuch--search initial)))

They're implemented in terms of a common search driver, with the only difference being how we show the final result of the auto-completion call.

When using them you'll notice how we automatically inject consult's split character (# by default) as the first one in the query string, so that our search term can be divided into a string passed to notmuch and a second half (after inserting a second # in our query) that is used by emacs to filter the results of the former. You'll also see that there's a preview available when traversing the list of candidates in the minibuffer.

It is also worth remembering that the input is a generic notmuch query, so one can, for instance, use the initial contents to define specific query commands. For example, i have a set of mailboxes under a subdirectory called feeds (where mails retrieved by rss2email end up after a dovecot sieve), so i could define this command in my init files:

(defun jao-consult-notmuch-feeds (&optional tree)
  (interactive "P")
  (let ((init "folder:/feeds/ "))
    (if tree (consult-notmuch-tree init) (consult-notmuch init))))

or be more generic and read from a completing prompt the subfolder of my ~/var/mail directory i want to use:

(defun jao-consult-notmuch-folder (&optional tree folder)
  (interactive "P")
  (let* ((root "~/var/mail/")
         (folder (if folder
                     (file-name-as-directory folder)
                     (thread-first (read-directory-name "Mailbox: " root)
                       (file-relative-name root))))
         (folder (replace-regexp-in-string "/\\(.\\)" "\\\\/\\1" folder))
         (init (format "folder:/%s " folder)))
    (if tree (consult-notmuch-tree init) (consult-notmuch init))))

(defun jao-consult-notmuch-feeds (&optional tree)
  (interactive "P")
  (jao-consult-folder tree "feeds"))

Customization

As customary, we're going to use a customization group, as a subgroup of notmuch's one:

(defgroup consult-notmuch nil
  "Options for consult-notmuch."
  :group 'notmuch)

Faces

We should be able to customize the faces used to display search results in the minibuffer, which can have as defaults the faces already defined by notmuch.el:

(defface consult-notmuch-date-face
  '((t :inherit notmuch-search-date))
  "Face used in matching messages for the date field.")

(defface consult-notmuch-count-face
  '((t :inherit notmuch-search-count))
  "Face used in matching messages for the mail count field.")

(defface consult-notmuch-authors-face
  '((t :inherit notmuch-search-matching-authors))
  "Face used in matching messages for the authors field.")

(defface consult-notmuch-subject-face
  '((t :inherit notmuch-search-subject))
  "Face used in matching messages for the subject field.")

Notmuch command

Usually, we won't need to customize the command we pass to consult since notmuch will be in our path and we don't need special flags for it, but just in case:

(defcustom consult-notmuch-command "notmuch search ARG"
  "Command to perform notmuch search."
  :type 'string)

Note that the *ARG* marker is important: it's where consult's async command helpers are going to insert our query string.

Implementation

Consult search function

The core of our implementation should a call to consult--read with a closure to obtain completion candidates based on a call to notmuch search as an asynchronous process. For that, we'll use consult's helper consult--async-command. This function takes as first argument a string representing the command to be called to obtain completion candidates, followed by any transformations we want to apply to them before being displayed. Thus, our candidates generator will look like:

(defun consult-notmuch--search (&optional initial)
  "Perform an asynchronous notmuch search via `consult--read'.
If given, use INITIAL as the starting point of the query."
  (consult--read (consult--async-command consult-notmuch-command
                   (consult--async-map #'consult-notmuch--transformer))
                 :prompt "Notmuch search: "
                 :require-match t
                 :initial (concat consult-async-default-split initial)
                 :history 'consult-notmuch-history
                 :state #'consult-notmuch--preview
                 :lookup #'consult--lookup-member
                 :category 'notmuch-result
                 :sort nil))

In the code above we're also using a preview function, defined below, and a history variable:

(defvar consult-notmuch-history nil
  "History for `consult-notmuch'.")

Parsing notmuch results

Our candidates generator uses the following transformer to pretty-print the raw results returned by the notmuch process:

(defun consult-notmuch--transformer (str)
  "Transform STR to notmuch display style."
  (when (string-match "thread:" str)
    (let* ((thread-id (car (split-string str "\\ +")))
           (date (substring str 24 37))
           (mid (substring str 24))
           (c0 (string-match "[[]" mid))
           (c1 (string-match "[]]" mid))
           (count (substring mid c0 (1+ c1)))
           (auths (truncate-string-to-width
                   (string-trim (nth 1 (split-string mid "[];]")))
                   consult-notmuch-authors-width))
           (subject (truncate-string-to-width
                     (string-trim (nth 1 (split-string mid "[;]")))
                     (- (frame-width)
                        2
                        consult-notmuch-counts-width
                        consult-notmuch-authors-width)))
           (fmt (format "%%s\t%%%ds\t%%%ds\t%%s"
                        consult-notmuch-counts-width
                        consult-notmuch-authors-width)))
      (propertize
       (format fmt
               (propertize date 'face 'consult-notmuch-date-face)
               (propertize count 'face 'consult-notmuch-count-face)
               (propertize auths 'face 'consult-notmuch-authors-face)
               (propertize subject 'face 'consult-notmuch-subject-face))
       'thread-id thread-id))))

We use our customizable faces, extract a number of substrings and play a little trick: to display our candidate, notmuch.el will need the thread identifier, but we don't want to show it in our nicely formatter minibuffer entry. We simply store it as a property of the candidate string, and will use the following helper function to recover it at display time:

(defun consult-notmuch--thread-id (candidate)
  "Recover the thread id for the given CANDIDATE string."
  (when candidate (get-text-property 0 'thread-id candidate)))

We have also made the width of the authors and counts fields customizable variables:

(defcustom consult-notmuch-authors-width 20
  "Maximum width of the authors column in search results."
  :type 'integer)

(defcustom consult-notmuch-counts-width 10
  "Minimum width of the counts column in search results."
  :type 'integer)

Displaying candidates

consult-notmuch--search is going to return a candidate, and we'll want to display it either as a single message or a tree. notmuch.el already provides functions for that, so our display functions are really simple. Let's start with the one showing previews.

Previews

We're going to use always the same buffer for previews, and close it when we're done:

(defvar consult-notmuch--buffer-name "*consult-notmuch*"
  "Name of preview and result buffers.")

(defun consult-notmuch--close-preview ()
  "Name says it all (and checkdoc is a bit silly, insisting on this)."
  (when (get-buffer consult-notmuch--buffer-name)
    (kill-buffer consult-notmuch--buffer-name)))

and use notmuch-show to show a candidate. Remember that we've stashed the thread id needed by that function as a property of of our candidate string, and provided an accessor for it:

(defun consult-notmuch--preview (candidate _restore)
  "Open resulting CANDIDATE in ‘notmuch-show’ view, in a preview buffer."
  (consult-notmuch--close-preview)
  (when-let ((thread-id (consult-notmuch--thread-id candidate)))
    (notmuch-show thread-id nil nil nil consult-notmuch--buffer-name)))

The additional _restore argument it's used by consult when we install the function above via consult--read's :state keyword.

Messages and trees

Displaying a message is practically identical to previewing it, we just change the buffer's name to include the query:

(defun consult-notmuch--show (candidate)
  "Open resulting CANDIDATE in ‘notmuch-show’ view."
  (consult-notmuch--close-preview)
  (when-let ((thread-id (consult-notmuch--thread-id candidate)))
    (let* ((subject (car (last (split-string candidate "\t"))))
           (title (concat consult-notmuch--buffer-name " " subject)))
      (notmuch-show thread-id nil nil nil title))))

and for a tree we just use notmuch-tree instead:

(defun consult-notmuch--tree (candidate)
  "Open resulting CANDIDATE in ‘notmuch-tree’."
  (consult-notmuch--close-preview)
  (when-let ((thread-id (consult-notmuch--thread-id candidate)))
    (notmuch-tree thread-id nil nil)))

Acknowledgements

This implementation was heavily inspired by Alexander Fu Xi's counsel-notmuch.

Tags: emacs pages
Creative Commons License
jao.io by jao is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.