Skip to content

Instantly share code, notes, and snippets.

@vedang
Last active June 25, 2024 04:34
Show Gist options
  • Save vedang/26a94c459c46e45bc3a9ec935457c80f to your computer and use it in GitHub Desktop.
Save vedang/26a94c459c46e45bc3a9ec935457c80f to your computer and use it in GitHub Desktop.
Notmuch configuration for Hey.com Style workflows.
- Specific Notmuch filters (and saved-searches) for:
+ The Feed (newsletters, blogs)
+ The Paper trail (receipts, ledger)
+ Screened Inbox (mail from folks you actually want to read)
+ Previously Seen (important mail that you've already read)
+ Unscreened Inbox (potential spam / stuff you don't want)
- Elisp Functions to move / categorize emails from a particular sender.
+ Adds tags needed by filters defined above to all email sent by a particular sender
+ Creates an entry in a DB file, which is used by the Notmuch post-new script when indexing new email, to auto-add the relevant tags.
- Shell script (the Notmuch post-new hook) to categorize emails when indexing them.
- Demo of this functionality: https://www.youtube.com/watch?v=wuSPssykPtE
;;; init-notmuch.el --- configuration for using notmuch to manage email
;;; Author: Vedang Manerikar
;;; Created on: 07th June 2014
;;; Copyright (c) 2014 Vedang Manerikar <vedang.manerikar@gmail.com>
;; This file is not part of GNU Emacs.
;;; License:
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the Do What The Fuck You Want to
;; Public License, Version 2, which is included with this distribution.
;; See the file LICENSE.txt
;;; Commentary:
;; Put this file somewhere on your load-path, after notmuch is loaded.
;; Eg:
;; (require 'notmuch)
;; (require 'init-notmuch)
;; The variable `notmuch-mail-dir' needs to be defined (for example,
;; in your personal.el file)
;;; Code:
(setq user-mail-address (notmuch-user-primary-email)
user-full-name (notmuch-user-name)
message-send-mail-function 'message-send-mail-with-sendmail
;; we substitute sendmail with msmtp
sendmail-program (executable-find "msmtp")
message-sendmail-envelope-from 'header
mail-specify-envelope-from t
notmuch-archive-tags '("-inbox" "-unread" "+archived")
notmuch-show-mark-read-tags '("-inbox" "-unread" "+archived")
notmuch-search-oldest-first nil
notmuch-show-indent-content nil
notmuch-hooks-dir (expand-file-name ".notmuch/hooks" notmuch-mail-dir))
;;; My Notmuch start screen:
(progn
(setq notmuch-saved-searches nil)
(push '(:name "Inbox"
:query "tag:inbox AND tag:screened AND tag:unread"
:key "i"
:search-type 'tree)
notmuch-saved-searches)
(push '(:name "Previously Seen"
:query "tag:screened AND NOT tag:unread"
:key "I")
notmuch-saved-searches)
(push '(:name "Unscreened"
:query "tag:inbox AND NOT tag:screened"
:key "s")
notmuch-saved-searches)
(push '(:name "The Feed"
:query "tag:thefeed"
:key "f"
:search-type 'tree)
notmuch-saved-searches)
(push '(:name "The Papertrail"
:query "tag:/ledger/"
:key "p")
notmuch-saved-searches))
;; Integrate with org-mode
(require 'ol-notmuch)
(eval-after-load 'notmuch-show
'(progn
;; Bindings in `notmuch-show-mode'
(define-key notmuch-show-mode-map (kbd "r")
'notmuch-show-reply)
(define-key notmuch-show-mode-map (kbd "R")
'notmuch-show-reply-sender)
(define-key notmuch-show-mode-map (kbd "C")
'vedang/notmuch-reply-later)
;; Bindings in `notmuch-search-mode'
(define-key notmuch-search-mode-map (kbd "r")
'notmuch-search-reply-to-thread)
(define-key notmuch-search-mode-map (kbd "R")
'notmuch-search-reply-to-thread-sender)
(define-key notmuch-search-mode-map (kbd "/")
'notmuch-search-filter)
(define-key notmuch-search-mode-map (kbd "A")
'vedang/notmuch-archive-all)
(define-key notmuch-search-mode-map (kbd "D")
'vedang/notmuch-delete-all)
(define-key notmuch-search-mode-map (kbd "L")
'vedang/notmuch-filter-by-from)
(define-key notmuch-search-mode-map (kbd ";")
'vedang/notmuch-search-by-from)
(define-key notmuch-search-mode-map (kbd "d")
'vedang/notmuch-search-delete-and-archive-thread)
;; The HEY Workflow Bindings
(define-key notmuch-search-mode-map (kbd "S")
'vedang/notmuch-move-sender-to-spam)
(define-key notmuch-search-mode-map (kbd "I")
'vedang/notmuch-move-sender-to-screened)
(define-key notmuch-search-mode-map (kbd "P")
'vedang/notmuch-move-sender-to-papertrail)
(define-key notmuch-search-mode-map (kbd "f")
'vedang/notmuch-move-sender-to-thefeed)
(define-key notmuch-search-mode-map (kbd "C")
'vedang/notmuch-reply-later)
;; Bindings in `notmuch-tree-mode'
(define-key notmuch-tree-mode-map (kbd "C")
'vedang/notmuch-reply-later)))
(defun vedang/notmuch-archive-all ()
"Archive all the emails in the current view."
(interactive)
(notmuch-search-archive-thread nil (point-min) (point-max)))
(defun vedang/notmuch-delete-all ()
"Archive all the emails in the current view.
Mark them for deletion by cron job."
(interactive)
(notmuch-search-tag-all '("+deleted"))
(vedang/notmuch-archive-all))
(defun vedang/notmuch-search-delete-and-archive-thread ()
"Archive the currently selected thread. Add the deleted tag as well."
(interactive)
(notmuch-search-add-tag '("+deleted"))
(notmuch-search-archive-thread))
(defun vedang/notmuch-tag-and-archive (tag-changes &optional beg end)
"Prompt the user for TAG-CHANGES.
Apply the TAG-CHANGES to region and also archive all the emails.
When called directly, BEG and END provide the region."
(interactive (notmuch-search-interactive-tag-changes))
(notmuch-search-tag tag-changes beg end)
(notmuch-search-archive-thread nil beg end))
(defun vedang/notmuch-search-get-from ()
"A helper function to find the email address for the given email."
(let ((notmuch-addr-sexp (car
(notmuch-call-notmuch-sexp "address"
"--format=sexp"
"--format-version=1"
"--output=sender"
(notmuch-search-find-thread-id)))))
(plist-get notmuch-addr-sexp :name-addr)))
(defun vedang/notmuch-tree-get-from ()
"A helper function to find the email address for the given email.
Assumes `notmuch-tree-mode'."
(plist-get (notmuch-tree-get-prop :headers) :From))
(defun vedang/notmuch-get-from ()
"Find the From email address for the email at point."
(car (notmuch-clean-address (cond
((eq major-mode 'notmuch-show-mode)
(notmuch-show-get-from))
((eq major-mode 'notmuch-tree-mode)
(vedang/notmuch-tree-get-from))
((eq major-mode 'notmuch-search-mode)
(vedang/notmuch-search-get-from))
((t nil))))))
(defun vedang/notmuch-filter-by-from ()
"Filter the current search view to show all emails sent from the sender of the current thread."
(interactive)
(notmuch-search-filter (concat "from:" (vedang/notmuch-get-from))))
(defun vedang/notmuch-search-by-from (&optional no-display)
"Show all emails sent from the sender of the current thread.
NO-DISPLAY is sent forward to `notmuch-search'."
(interactive)
(notmuch-search (concat "from:" (vedang/notmuch-get-from))
notmuch-search-oldest-first
nil
nil
no-display))
(defun vedang/notmuch-tag-by-from (tag-changes &optional beg end refresh)
"Apply TAG-CHANGES to all emails from the sender of the current thread.
BEG and END provide the region, but are ignored. They are defined
since `notmuch-search-interactive-tag-changes' returns them. If
REFRESH is true, refresh the buffer from which we started the
search."
(interactive (notmuch-search-interactive-tag-changes))
(let ((this-buf (current-buffer)))
(vedang/notmuch-search-by-from t)
;; This is a dirty hack since I can't find a way to run a
;; temporary hook on `notmuch-search' completion. So instead of
;; waiting on the search to complete in the background and then
;; making tag-changes on it, I will just sleep for a short amount
;; of time. This is generally good enough and works, but is not
;; guaranteed to work every time. I'm fine with this.
(sleep-for 0.5)
(notmuch-search-tag-all tag-changes)
(when refresh
(set-buffer this-buf)
(notmuch-refresh-this-buffer))))
(defun vedang/notmuch-add-addr-to-db (nmaddr nmdbfile)
"Add the email address NMADDR to the db-file NMDBFILE."
(append-to-file (format "%s\n" nmaddr) nil nmdbfile))
(defun vedang/notmuch-move-sender-to-thefeed ()
"For the email at point, move the sender of that email to the feed.
This means:
1. All new email should go to the feed and skip the inbox altogether.
2. All existing email should be updated with the tag =thefeed=.
3. All existing email should be removed from the inbox."
(interactive)
(vedang/notmuch-add-addr-to-db (vedang/notmuch-get-from)
(format "%s/thefeed.db" notmuch-hooks-dir))
(vedang/notmuch-tag-by-from '("+thefeed" "+archived" "-inbox")))
(defun vedang/notmuch-move-sender-to-papertrail (tag-name)
"For the email at point, move the sender of that email to the papertrail.
This means:
1. All new email should go to the papertrail and skip the inbox altogether.
2. All existing email should be updated with the tag =ledger/TAG-NAME=.
3. All existing email should be removed from the inbox."
(interactive "sTag Name: ")
(vedang/notmuch-add-addr-to-db (format "%s %s"
tag-name
(vedang/notmuch-get-from))
(format "%s/ledger.db" notmuch-hooks-dir))
(let ((tag-string (format "+ledger/%s" tag-name)))
(vedang/notmuch-tag-by-from (list tag-string "+archived" "-inbox" "-unread"))))
(defun vedang/notmuch-move-sender-to-screened ()
"For the email at point, move the sender of that email to Screened Emails.
This means:
1. All new email should be tagged =screened= and show up in the inbox.
2. All existing email should be updated to add the tag =screened=."
(interactive)
(vedang/notmuch-add-addr-to-db (vedang/notmuch-get-from)
(format "%s/screened.db" notmuch-hooks-dir))
(vedang/notmuch-tag-by-from '("+screened")))
(defun vedang/notmuch-move-sender-to-spam ()
"For the email at point, move the sender of that email to spam.
This means:
1. All new email should go to =spam= and skip the inbox altogether.
2. All existing email should be updated with the tag =spam=.
3. All existing email should be removed from the inbox."
(interactive)
(vedang/notmuch-add-addr-to-db (vedang/notmuch-get-from)
(format "%s/spam.db" notmuch-hooks-dir))
(vedang/notmuch-tag-by-from '("+spam" "+deleted" "+archived" "-inbox" "-unread" "-screened")))
(defun vedang/notmuch-reply-later ()
"Capture this email for replying later."
(interactive)
;; You need `org-capture' to be set up for this to work. Add this
;; code somewhere in your init file after `org-cature' is loaded:
;; (push '("r" "Respond to email"
;; entry (file org-default-notes-file)
;; "* TODO Respond to %:from on %:subject :email: \nSCHEDULED: %t\n%U\n%a\n"
;; :clock-in t
;; :clock-resume t
;; :immediate-finish t)
;; org-capture-templates)
(org-capture nil "r")
;; The rest of this function is just a nice message in the modeline.
(let* ((email-subject (format "%s..."
(substring (notmuch-show-get-subject) 0 15)))
(email-from (format "%s..."
(substring (notmuch-show-get-from) 0 15)))
(email-string (format "%s (From: %s)" email-subject email-from)))
(message "Noted! Reply Later: %s" email-string)))
;; Sign messages by default.
(add-hook 'message-setup-hook 'mml-secure-sign-pgpmime)
(setq notmuch-address-selection-function
(lambda (prompt collection initial-input)
(completing-read prompt
(cons initial-input collection)
nil
t
nil
'notmuch-address-history)))
(defun disable-auto-fill ()
"I don't want `auto-fill-mode'."
(auto-fill-mode -1))
(add-hook 'message-mode-hook 'disable-auto-fill)
(provide 'init-notmuch)
;;; init-notmuch.el ends here
#!/bin/bash
# Based On: https://gist.githubusercontent.com/frozencemetery/5042526/raw/57195ba748e336de80c27519fe66e428e5003ab8/post-new
# Install this by moving this file to <maildir>/.notmuch/hooks/post-new
# NOTE: you need to define your maildir in the vardiable nm_maildir (just a few lines below in this script)
# Also create empty files for:
# 1. thefeed.db (things you want to read every once in a while)
# 2. spam.db (things you never want to see)
# 3. screened.db (your inbox)
# 4. ledger.db (papertrail)
# in the hooks folder.
# More info about hooks: https://notmuchmail.org/manpages/notmuch-hooks-5/
echo "starting NM post-new script"
# NOTE: You will need to define your maildir here!
export nm_maildir="$HOME/Documents/mail"
export start="-1"
function timer_start {
echo -n " starting $1"
export start=$(date +"%s")
}
function timer_end {
end=$(date +"%s")
delta=$(($end-$start))
mins=$(($delta / 60))
secs=$(($delta - ($mins*60)))
echo " -- $1 completed: ${mins} minutes, ${secs} seconds"
export start="-1" # sanity requires this or similar
}
timer_start "ledger"
while IFS= read -r line; do
nm_tag=$(echo "$line" | cut -d' ' -f1 -)
nm_entry=$(echo "$line" | cut -d' ' -f2 -)
if [ -n "$nm_entry" ]; then
/usr/local/bin/notmuch tag +archived +ledger/"$nm_tag" -inbox -- tag:inbox and tag:unread and from:"$nm_entry"
fi
# echo -n "Handling entry: $nm_tag, $nm_entry"
done < $nm_maildir/.notmuch/hooks/ledger.db
timer_end "ledger"
timer_start "unsubscribable_spam"
for entry in $(cat $nm_maildir/.notmuch/hooks/spam.db); do
if [ -n "$entry" ]; then
/usr/local/bin/notmuch tag +spam +deleted +archived -inbox -unread -- tag:inbox and tag:unread and from:"$entry"
fi
done
timer_end "unsubscribable_spam"
timer_start "thefeed"
for entry in $(cat $nm_maildir/.notmuch/hooks/thefeed.db); do
if [ -n "$entry" ]; then
/usr/local/bin/notmuch tag +thefeed +archived -inbox -- tag:inbox and tag:unread and from:"$entry"
fi
done
timer_end "thefeed"
timer_start "Screened"
for entry in $(cat $nm_maildir/.notmuch/hooks/screened.db); do
if [ -n "$entry" ]; then
/usr/local/bin/notmuch tag +screened -- tag:inbox and tag:unread and from:"$entry"
fi
done
timer_end "Screened"
echo "Completing NM post-new script; goodbye"
@vedang
Copy link
Author

vedang commented Aug 8, 2020

@DivineDominion: It does not do anything different from car, it is simply an alias for it. I'll modify this gist to use car instead. Thank you for pointing this out!

@DivineDominion
Copy link

@vedang While you're refactoring, you might want to change function name references from 'foo to #'foo: this format will be checked by the Emacs Lisp compiler, as opposed to just producing runtime crashes in case you made a typo. See https://endlessparentheses.com/get-in-the-habit-of-using-sharp-quote.html

@DivineDominion
Copy link

@vedang I noticed that during byte compilation of your helpers that emacs complains about (cond ... ((t nil))) https://gist.github.com/vedang/26a94c459c46e45bc3a9ec935457c80f#file-init-notmuch-el-L160

-- and in fact you can just drop that, because when no other condition was true, cond by default returns nil anyway.

@akho
Copy link

akho commented Oct 31, 2021

You could use notmuch tag --batch — it's supposed to be faster than a few hundred separate notmuch tag launches, and would simplify your Bash script (and maybe also the Elisp...) considerably. You can also get away with having just one "db" file, if you write the correct format from Emacs.

@agenbite
Copy link

Couldn't you get away without any db? By writing the queries into a file or through afew, for example. What's the need for databases? Is it expected to be faster?

@DivineDominion
Copy link

@agenbite When I read it I thought the same -- but the .db files are just plain text files with 1 notmuch search expression per line. So the feed.db would contain lines of sender addresses by default, but I modified mine to e.g. add conditions that help let regular emails through:

from:no-reply@kickstarter.com and subject:"Update"

@agenbite
Copy link

Yeah, @DivineDominion, that's what I thought. What is called "db" here is no more than a file containing a set of queries, am I right? These queries define filters, so, in principle, you might use afew to manage those filters.

Could you please share your approach?

@DivineDominion
Copy link

@agenbite I haven't deviated a lof of @vedang's approach in the past year. The line I shared in the previous comment was one of the more interesting ones already :) What else would you find interesting to see?

afew looks interesting, never tried that https://afew.readthedocs.io/en/latest/

@agenbite
Copy link

@DivineDominion, if it's mostly @vedang's approach I'm fine, thanks! :)

@mgraham
Copy link

mgraham commented May 29, 2022

@vedang - this is awesome! Thank you!

@mgraham
Copy link

mgraham commented May 29, 2022

Here's a version of post-new.sh that uses saved queries instead of tags
(obviously have to update notmuch-saved-searches as well)

#!/bin/bash
# Based On: https://gist.githubusercontent.com/frozencemetery/5042526/raw/57195ba748e336de80c27519fe66e428e5003ab8/post-new
# Install this by moving this file to <maildir>/.notmuch/hooks/post-new
# NOTE: you need to define your maildir in the vardiable nm_maildir (just a few lines below in this script)
# Also create empty files for:
# 1. thefeed.db (things you want to read every once in a while)
# 2. spam.db (things you never want to see)
# 3. screened.db (your inbox)
# 4. ledger.db (papertrail)
# in the hooks folder.
# More info about hooks: https://notmuchmail.org/manpages/notmuch-hooks-5/

# NOTE: You will need to define your maildir here!

export notmuch_command=/usr/bin/notmuch
export nm_maildir=`$notmuch_command config get database.path`

export start="-1"

function set_query() {
  query_name=$1
  static_query=$2
  file=$3
  query=""
  while IFS="" read -r entry || [ -n "$entry" ]
  do
    if [ -n "$query" ]; then
        query="$query OR "
    fi
    query="$query(from:$entry)"
  done < $nm_maildir/.notmuch/hooks/$file
  if [ -n "$query" ]; then
    $notmuch_command config set --database query.$query_name "$static_query($query)"
  fi
}

set_query ledger "tag:inbox and tag:unread and " ledger.db
set_query thefeed "tag:inbox and tag:unread and " thefeed.db
set_query spam "tag:inbox and tag:unread and " spam.db
set_query screened "tag:inbox and tag:unread and " screened.db

Edit: added the --database flag to the notmuch config set command. This makes the saved queries play nice with afew

@akho
Copy link

akho commented May 29, 2022

I keep a tagging query file of this form:

-inbox +papertrail -- tag:inbox from:noreply@cloudpayments.org
-inbox +feed -- tag:inbox from:bingo@patreon.com subject:"just shared"
...

And then have a postNew hook that's just

notmuch tag --batch --input=<path>/notmuch-inbox-retag

Note the lack of loops and per-tag files. You can also add arbitrary queries to the batch file if you need something that's not just sender-based (note the Patreon query above). Elisp functions append queries to the file.

I think it's much simpler and more flexible.

@DivineDominion
Copy link

Didn't know that was possible, thank you for sharing!

@DivineDominion
Copy link

Migrated to the batch input file. While I now have to repeat the +feed setting for every line, it also makes customizations possible. That's neat.

The helper to add a sender to the feed.db file needed adjustment, of course, to be complete:

(defun vedang/notmuch-add-addr-to-db (nmaddr nmdbfile)
  "Add the email address NMADDR to the db-file NMDBFILE with a `from:` prefix."
  (append-to-file (format "+feed -- from:%s\n" nmaddr) nil nmdbfile))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment