Skip to content

emacsmirror/consult-notmuch

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

68 Commits
 
 
 
 
 
 
 
 

Repository files navigation

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

Consult interface to notmuch search

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"))

An appetiser: address search

As a simple example of how the simplest asynchronous consult query looks, let’s write a function that allows us to select a mail address using a notmuch address query, which in the CLI looks like

notmuch address --format=text <query>

A brute force approach is to issue the command above synchronously, split the result, and use completing-read:

(defun read-notmuch-address (input)
  (let* ((cmd (format "notmuch address --format=text '%s'" input))
         (res (shell-command-to-string cmd)))
    (completing-read "Notmuch address: " (split-string res "\n"))))

If your notmuch database is not that big, this approach actually works reasonably well, but it doesn’t scale with its size. That’s where an asynchronous command can help: we call the shell command as the input varies, collecting and displaying the results as they’re available. Here’s how that looks using consult asynchronous command infrastructure:

(defun consult-notmuch--address-command (input)
  "Spec for an async command querying a notmuch address with INPUT."
  `(,notmuch-command "address" "--format=text" ,input))

(defun consult-notmuch-address-compose (address)
  "Compose an email to a given ADDRESS."
  (let ((other-headers (and notmuch-always-prompt-for-sender
                            `((From . ,(notmuch-mua-prompt-for-sender))))))
    (notmuch-mua-mail address
                      nil
                      other-headers
                      nil
                      (notmuch-mua-get-switch-function))))

(defun consult-notmuch--address-prompt ()
  (consult--read (consult--async-command #'consult-notmuch--address-command)
                 :prompt "Notmuch addresses: "
                 :sort nil
                 :category 'notmuch-address))

;;;###autoload
(defun consult-notmuch-address (&optional multi-select-p initial-addr)
  "Search the notmuch db for an email address and compose mail to it.
With a prefix argument, prompt multiple times until there
is an empty input."
  (interactive "P")
  (if multi-select-p
      (cl-loop for addr = (consult-notmuch--address-prompt)
               until (eql (length addr) 0)
               collect addr into addrs
               finally (consult-notmuch-address-compose
                        (mapconcat #'identity
                                   (if initial-addr
                                       (cons initial-addr addrs)
                                     addrs)
                                   ", ")))
    (consult-notmuch-address-compose (consult-notmuch--address-prompt))))

Buffer narrowing

If you have many buffers, you may want a convenient way to switch specifically among notmuch buffers. The consult-notmuch-buffer-source source can be used for this purpose:

(defun consult-notmuch--interesting-buffers ()
  "Return a list of names of buffers with interesting notmuch data."
  (consult--buffer-query
   :as (lambda (buf)
         (when (notmuch-interesting-buffer buf)
           (buffer-name buf)))))

;;;###autoload
(defvar consult-notmuch-buffer-source
  '(:name "Notmuch Buffer"
    :narrow (?n . "Notmuch")
    :hidden t
    :category buffer
    :face consult-buffer
    :history buffer-name-history
    :state consult--buffer-state
    :items consult-notmuch--interesting-buffers)
  "Notmuch buffer candidate source for `consult-buffer'.")

This source can be used with consult-buffer by adding it to consult-buffer-sources:

(add-to-list 'consult-buffer-sources 'consult-notmuch-buffer-source)

With the above configuration, you can initiate consult-buffer and then type n followed by a space to narrow the set of buffers to just notmuch buffers.

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 'consult)

and our first user option will tell us whether we display single messages in the matches list (extracted via notmuch-show) or thread groups (a la notmuch-search):

(defcustom consult-notmuch-show-single-message t
  "Show only the matching message or the whole thread in listings."
  :type 'boolean)

When displaying search results in the minibuffer, we’ll want to extract the authors, date and subject and thread count for each message and give them a format defined by the custom variable:

(defcustom consult-notmuch-result-format
  '(("date" . "%12s  ")
    ("count" . "%-7s ")
    ("authors" . "%-20s")
    ("subject" . "  %-54s")
    ("tags" . " (%s)"))
  "Format for matching candidates in minibuffer.
Supported fields are: date, authors, subject, count and tags."
  :type '(alist :key-type string :value-type string))

which has the same semantics as notmuch-search-result-format.

And we can also add a flag that tells us whether to show the oldest or the newest messages matching a search first:

(defcustom consult-notmuch-newest-first t
  "List messages newest first (defaults to oldest first)."
  :type 'boolean)

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 or notmuch show 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--command (input)
  "Construct a search command for emails containing INPUT."
  (let ((sort (if consult-notmuch-newest-first
                  "--sort=newest-first"
                "--sort=oldest-first")))
    (if consult-notmuch-show-single-message
        `(,notmuch-command "show" "--body=false" ,sort ,input)
      `(,notmuch-command "search" ,sort ,input))))

(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."
  (setq consult-notmuch--partial-parse nil)
  (consult--read (consult--async-command
                     #'consult-notmuch--command
                   (consult--async-filter #'identity)
                   (consult--async-map #'consult-notmuch--transformer))
                 :prompt "Notmuch search: "
                 :require-match t
                 :initial (consult--async-split-initial initial)
                 :history '(:input 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 (described below), and a history variable:

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

and the candidates transformer will depend on whether we’re displaying threads or single messages:

(defun consult-notmuch--transformer (str)
  "Transform STR to notmuch display style."
  (if consult-notmuch-show-single-message
      (consult-notmuch--show-transformer str)
    (consult-notmuch--search-transformer str)))

Formatting search results

Using consult-notmuch-result-format, we are going to return a string representation from a plist describing the current message, reusing notmuch’s facility notmuch-tree-format-field, with the added trick of storing the current message or thread id in a text property, so that it can latter be used for displaying the message preview:

(defun consult-notmuch--format-field (spec msg)
  "Return a string for SPEC given the MSG metadata."
  (let ((field (car spec)))
    (cond ((equal field "count")
           (when-let (cnt (plist-get msg :count))
             (format (cdr spec) cnt)))
          ((equal field "tags")
           (when (plist-get msg :tags)
             (notmuch-tree-format-field "tags" (cdr spec) msg)))
          (t (notmuch-tree-format-field field (cdr spec) msg)))))

(defun consult-notmuch--format-candidate (msg)
  "Format the result (MSG) of parsing a notmuch show information unit."
  (when-let (id (plist-get msg :id))
    (let ((result-string))
      (dolist (spec consult-notmuch-result-format)
        (when-let (field (consult-notmuch--format-field spec msg))
          (setq result-string (concat result-string field))))
      (propertize result-string 'id id 'tags (plist-get msg :tags)))))

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

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

Parsing notmuch show results

When consult-notmuch-show-single-message is set to nil, we’re showing single messages as completion candidates, and, therefore, we are going to need to parse the output of that command, which looks like:

     message{ id:emacs-circe/circe/issues/401@github.com depth:0 ...
     header{
<Sender (tags)>
Subject: <subject>
From: <from>
To: <to>
...
Date: Fri, 03 Sep 2021 12:46:53 -0700
     header}
message}

Now, all we need is to parse the output of notmuch show and fill in the message metadata plist:

(defvar consult-notmuch--partial-parse nil
  "Internal variable for parsing status.")
(defvar consult-notmuch--partial-headers nil
  "Internal variable for parsing status.")
(defvar consult-notmuch--info nil
  "Internal variable for parsing status.")

(defun consult-notmuch--set (k v)
  "Set the value V for property K in the message we're currently parsing."
  (setq consult-notmuch--partial-parse
        (plist-put consult-notmuch--partial-parse k v)))

(defun consult-notmuch--show-transformer (str)
  "Parse output STR of notmuch show, extracting its components."
  (if (string-prefix-p "message}" str)
      (prog1
          (consult-notmuch--format-candidate
           (consult-notmuch--set :headers consult-notmuch--partial-headers))
        (setq consult-notmuch--partial-parse nil
              consult-notmuch--partial-headers nil
              consult-notmuch--info nil))
    (cond ((string-match "message{ \\(id:[^ ]+\\) .+" str)
           (consult-notmuch--set :id (match-string 1 str))
           (consult-notmuch--set :match t))
          ((string-prefix-p "header{" str)
           (setq consult-notmuch--info t))
          ((and str consult-notmuch--info)
           (when (string-match "\\(.+\\) (\\([^)]+\\)) (\\([^)]*\\))$" str)
             (consult-notmuch--set :Subject (match-string 1 str))
             (consult-notmuch--set :date_relative (match-string 2 str))
             (consult-notmuch--set :tags (split-string (match-string 3 str))))
           (setq consult-notmuch--info nil))
          ((string-match "\\(Subject\\|From\\|To\\|Cc\\|Date\\): \\(.+\\)?" str)
           (let ((k (intern (format ":%s" (match-string 1 str))))
                 (v (or (match-string 2 str) "")))
             (setq consult-notmuch--partial-headers
                   (plist-put consult-notmuch--partial-headers k v)))))
    nil))

Parsing notmuch search results

When consult-notmuch-show-single-message is set, our candidates generator uses the following transformer to format the raw results returned by the notmuch search command. Here, every line contains already all elements we need:

(defun consult-notmuch--search-transformer (str)
  "Transform STR from notmuch search to notmuch display style."
  (when (string-match "thread:" str)
    (let* ((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 (string-trim (nth 1 (split-string mid "[];]"))))
           (subject (string-trim (nth 1 (split-string mid "[;]"))))
           (headers (list :Subject subject :From auths))
           (t0 (string-match "([^)]*)\\s-*$" mid))
           (tags (split-string (substring mid (1+  t0) -1)))
           (msg (list :id id
                      :match t
                      :headers headers
                      :count count
                      :date_relative date
                      :tags tags)))
      (consult-notmuch--format-candidate msg))))

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, closing it when we’re done, and use notmuch-show to show a candidate. Remember that we’ve stashed the message or thread id needed by that function as a property of of our candidate string, and provided an accessor for it, so we have all the ingredients:

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

(defun consult-notmuch--show-id (id buffer)
  "Show message or thread id in the requested buffer"
  (let ((notmuch-show-only-matching-messages
         consult-notmuch-show-single-message))
    (notmuch-show id nil nil nil buffer)))

(defun consult-notmuch--preview (action candidate)
  "Preview CANDIDATE when ACTION is 'preview."
  (cond ((eq action 'preview)
         (when-let ((id (consult-notmuch--candidate-id candidate)))
           (when (get-buffer consult-notmuch--buffer-name)
             (kill-buffer consult-notmuch--buffer-name))
           (consult-notmuch--show-id id consult-notmuch--buffer-name)))
        ((eq action 'exit)
         (when (get-buffer consult-notmuch--buffer-name)
           (kill-buffer consult-notmuch--buffer-name)))))
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."
  (when-let ((id (consult-notmuch--candidate-id candidate)))
    (let* ((subject (car (last (split-string candidate "\t"))))
           (title (concat consult-notmuch--buffer-name " " subject)))
      (consult-notmuch--show-id id title))))

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

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

Integration with Embark

Embark actions

We can integrate consult-notmuch with Embark by defining a keymap with actions on notmuch messages and associating it with the completion category of notmuch-result. In this keymap we associate + and - (like in notmuch buffers) to a function that tags a message:

(defvar consult-notmuch-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "+") 'consult-notmuch-tag)
    (define-key map (kbd "-") 'consult-notmuch-tag)
    map)
  "Keymap for actions on Notmuch entries.")

(set-keymap-parent consult-notmuch-map embark-general-map)
(add-to-list 'embark-keymap-alist '(notmuch-result . consult-notmuch-map))

Additionally, we should integrate our address selection functions as well, so that you can act on the addresses.

(defun consult-notmuch--address-to-multi-select (address)
  "Select more email addresses, in addition to the current selection"
  (consult-notmuch-address t address))

(defvar consult-notmuch-address-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "c") #'consult-notmuch-address-compose)
    (define-key map (kbd "m") #'consult-notmuch--address-to-multi-select)
    map))

(set-keymap-parent consult-notmuch-address-map embark-general-map)
(add-to-list 'embark-keymap-alist
             '(notmuch-address . consult-notmuch-address-map))

consult-notmuch-tag should take as argument the search result as a propertized message string. Because Embark feeds it this string, this function does not need to be interactive:

(defun consult-notmuch-tag (msg)
  (when-let* ((id (consult-notmuch--candidate-id msg))
              (tags (consult-notmuch--candidate-tags msg))
              (tag-changes (notmuch-read-tag-changes tags "Tags: " "+")))
    (notmuch-tag (concat "(" id ")") tag-changes)))

We can also create bespoke functions to automatically tag a message with certain tags using Embark. For example, here is a function that returns a tagger:

(defun consult-notmuch-make-tagger (tags)
  "Make a function to tag a message with TAGS."
  (lambda (msg)
    "Tag a notmuch message using Embark."
    (when-let ((id (consult-notmuch--candidate-id msg)))
      (notmuch-tag (concat "(" id ")") (split-string tags)))))

We use this to map Embark actions that trash, archive or flag messages to d, a and f respectively:

(define-key consult-notmuch-map (kbd "d") (consult-notmuch-make-tagger "+trash -inbox"))
(define-key consult-notmuch-map (kbd "a") (consult-notmuch-make-tagger "-inbox"))
(define-key consult-notmuch-map (kbd "f") (consult-notmuch-make-tagger "+flagged"))

Embark export

To export search results to a notmuch search buffer with Embark, we can define a configurable exporter:

(defvar consult-notmuch-export-function #'notmuch-search
  "Function used to ask notmuch to display a list of found ids.
Typical options are notmuch-search and notmuch-tree.")

(defun consult-notmuch-export (msgs)
  "Create a notmuch search buffer listing messages."
  (funcall consult-notmuch-export-function
   (concat "(" (mapconcat #'consult-notmuch--candidate-id msgs " ") ")")))

Associating this exporter with consult-notmuch is a matter of adding to embark-exporters-alist:

(add-to-list 'embark-exporters-alist
             '(notmuch-result . consult-notmuch-export))

Package boilerplate

consult-notmuch.el

The file consult-notmuch.el is automatically generated from this org document, and has the typical breakdown in sections of an emacs package:

;;; consult-notmuch.el --- Notmuch search using consult  -*- lexical-binding: t; -*-

<<package-boilerplate>>

;;; Code:

<<dependencies>>

<<customization>>

<<private-functions>>

;; Embark Integration:
(with-eval-after-load 'embark
  <<embark-actions>>)

<<public-functions>>

(provide 'consult-notmuch)
;;; consult-notmuch.el ends here

ELPA headers

The standard header boilerplate will make it publishable as a regular ELPA package

;; Author: Jose A Ortega Ruiz <jao@gnu.org>
;; Maintainer: Jose A Ortega Ruiz
;; Keywords: mail
;; License: GPL-3.0-or-later
;; Version: 0.8.1
;; Package-Requires: ((emacs "26.1") (consult "0.9") (notmuch "0.31"))
;; Homepage: https://codeberg.org/jao/consult-notmuch

License (GPL 3+)

;; Copyright (C) 2021, 2022, 2024  Jose A Ortega Ruiz

;; 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 <https://www.gnu.org/licenses/>.

Commentary blurb

;;; Commentary:

;; This package provides two commands using consult to query notmuch
;; emails and present results either as single emails
;; (`consult-notmuch') or full trees (`consult-notmuch-tree').
;;
;; The package also defines a narrowing source for `consult-buffer',
;; which can be activated with
;;
;;   (add-to-list 'consult-buffer-sources 'consult-notmuch-buffer-source)

;; This elisp file is automatically generated from its literate
;; counterpart at
;; https://codeberg.org/jao/consult-notmuch/src/branch/main/readme.org

Acknowledgements

The initial implementation of consult-notmuch was heavily inspired by Alexander Fu Xi’s counsel-notmuch.

This package also contains code contributions from Karthik Chikmagalur and Miciah Masters, and has also benefited from their ideas for new functionaliy.

S.M Mukarram Nainar suggested the idea and a working implementation for consult-notmuch-address.