20120801

Adding Notes to Emails in Gnus (in Summary View)

Sensitive information blanked
The gnusnotes.el package, with delete and edit capabilities is now available in the marmalade package repository. This post just explains why, how and what (code at the end, too)

Last month I switched email clients (for the 4th time in the past year.) I've passed through gnus, Thunderbird, mail.app and settled with Sparrow. Just 2 days before Sparrow was bought by Google I left for gnus (lucky moment!) Smaller memory footprint, no harddisk hungry indexes. Works wonders. Would only be happier with a newer Macbook. Oh well.

I'm still unsure about going the hardcore way with offlineimap (dovecot, notmuch,) but I'm more than happy with the current setup. I can move, delete or archive emails with just a few keystrokes. But I'm not in inbox zero yet (won't be ever, I think.) I leave some mails in my inbox when I need to follow up later (to make sure I'm on top of things.) Problem is, when you have more than 7 or 8 emails like this you start to lose touch. Furthermore, subject lines sometimes are not very descriptive "Request" is not the best subject. When more than 4 or 5 emails are of this kind, you need to check the email to know what it is about. Losing time. No way!

After all, this is emacs. If you don't like something, or want something, you write it (well, after looking for it.) My goal? Adding notes to email messages. I googled quickly for this with no luck, checked Sacha's emacs lisp files just in case. So... Time for some emacs lisping.

You may wonder: dude, use org-capture! This would be a good solution if I looked more often my todo list. But I only look my todo list when, well, I have to do something. Somehow stay-on-top things just distract me in my todo. I'd rather have 15 emails in my inbox than 15 more tasks in my already crowded todo list. I still use org-capture with emails, but for long-term catchups or long tasks where I have to do something in the future. When I just have to make sure someone does their work, I leave the email. Works for me, somehow. Getting back to lisp-topic...

It's not my first dive into emacs lisp: I've written some helper functions to do small things (wrote a wraper to the wonderful mark-multiple package a few weeks ago and I'm already using it to mark stuff at point,) I even wrote a major mode to simplify posting to mostlymaths.net, by adding my most usual HTML or CSS idioms with just a few keystrokes. The gnusnotes.el project was harder, though.

First thing was planning how to make it work. The simplest solution: get some kind of email ID, and save it to file tied to a note. I thought for 3 minutes, no need to make it more complex than necessary. Just like a CSV file, I used comma as ID-note separator, newline as ID-note pair separator. Luckily, gnus works because it has the correct pieces. In no time I had found which function determines the current message header data, and among them, message ID (the ID used in backend operations.) Once I had that, I needed a function to find the ID in the file. It was pretty straightforward (find-file and a few search-forward commands.) Something was trickier, though...

Gnus has two main windows: Groups and group summaries. For example, in Groups I have INBOX and sent-201208 as visible folders, I can check all folders (usually hidden) with L. When I open INBOX from here, I go to the INBOX summary, where all unread and marked as important emails show (and this is where my time is usually spent.) I want my notes to appear here, in the summary view. Not inside the email, not disjoint from emails. How!?

Well, for each email a summary line is generated, according to some rules you can set. You can add any detail from the email: headers, references, from, subject, date... And there is also a whole range of custom functions. Adding the flag %u you can choose from a wide range of functions (followed by any letter): In my case, %un calls gnus-user-format-function-n. This function does the lifting: checking the list of notes, for each email.

Problem: even if my notes file is small, hitting it for each email in my inbox can be dreadful. Even worse, the hit would be for any email in any folder. What if I check some archived folder with tens of thousands of emails? Well, these considerations are important, but I just found out that opening a file-buffer for each email somehow broke something within gnus, and filled the buffer with garbage. Later rationalisation turned out these problems :D

The solution (at least the one I used) is to generate a list of cons (msgid . note) each time a group is opened or a note is added. This is done via hooks, tying the loader function to the group preparation hook. Perfect! What else? The function looks for the msgid in this list of notes, and returns the note if successfull (and nothing if unsuccessful.)

To finish, just some cosmetic tweaks: adding some prettyfying of notes via highlight-regexp tied to another hook: the word note appears in italics and with the warning face, and the note appears with the default face. I'm not sure how it will look in vanilla emacs, but with solarized-dark I enjoy the colors. Completely tweakable, though.

By the way, my custom summary line is:

(setq-default
 gnus-summary-line-format "%U%R%z %(%&user-date;  %-15,15f  %B%s%) %un\n"
 gnus-user-date-format-alist '((t . "%Y-%m-%d (%a) %H:%M")
                               gnus-thread-sort-functions '(gnus-thread-sort-by-date)
                               )
 )

This is not yet a neat package with customizable options (yet). There's even no way to remove or edit notes (except by editing the notes file) since making this work appeared harder than editing this file by hand for the time being... But I may do it some day or another, it's not rocket science. Just needs some refactoring of the note-finder. Source code below, with many messages commented for dirty debugging. Enjoy!

;;; gnusnotes.el --- 

;; Copyright (C) 2012  Ruben Berenguel <ruben@dreamattic.com>

;; Author: Ruben Berenguel <ruben@dreamattic.com>
;; Keywords: comm, mail

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

;;; Commentary:

;; Still in beta. Use it at your own risk. Customize the note file
;; location and to install

;; (require 'gnusnotes)

;; Only tested in emacs 24.1.1 with NoGnus 0.18

;;; Code:


(provide 'gnusnotes)
;;; gnusnotes.el ends here


(defun gnus-user-format-function-n (data)
  (or (rberenguel/get-gnus-notes (aref data 0)) "")
)

(setq gnus-summary-notes "NOTE:")

(defun rberenguel/gnus-summary-notes-hl ()
  (interactive)
  (hi-lock-mode -1)
  (hi-lock-mode 1)
  ;(message "Highlighting %s" gnus-summary-notes)
  (highlight-regexp gnus-summary-notes 'warning)
  (highlight-regexp gnus-summary-notes 'italic)
  (highlight-regexp (concat gnus-summary-notes "\.\*") 'default)
)

(setq gnus-summary-prepare-hook 'rberenguel/gnus-summary-notes-hl)
(setq gnus-summary-hook 'rberenguel/gnus-summary-notes-hl)

;(add-hook 'gnus-summary-generate-hook 'rberenguel/gnus-summary-notes-hl)

(setq rberenguel/gnus-notes-file "~/.gnusnotes")

(setq gnus-select-group-hook
      '(lambda ()
         ;; First of all, sort by date.
         (setq rberenguel/gnus-notes-list (rberenguel/load-gnus-notes))))

(defun rberenguel/load-gnus-notes ()
  (interactive)
  (set-buffer (find-file rberenguel/gnus-notes-file))
  (goto-char 1)
  (setq moreLines t)
  (setq rberenguel/gnus-notes-list ())
  (while moreLines
    (beginning-of-line)        
    (setq p1 (point))
    (if (search-forward "," nil t)
        (progn (setq p2 (- (point) 1))
               (setq msgid (buffer-substring-no-properties p1 p2))
               ;(message "%s" msgid)
               (end-of-line)
               (setq p1 (point))
               (setq note (buffer-substring-no-properties (+ 1 p2) p1))
               ;(message "%s" note)
               (setq rberenguel/gnus-notes-list (append rberenguel/gnus-notes-list (list (cons msgid note))))
               ;(message "%s" rberenguel/gnus-notes-list)
          )
      
      )
    (setq moreLines (= 0 (forward-line 1)))
    )
  (kill-buffer)
  rberenguel/gnus-notes-list
  )

(defun rberenguel/get-gnus-notes (msgid)
  ;(message "Get notes: %s" msgid)
  (setq gnus-note "")
  (unless (null rberenguel/gnus-notes-list)
    (dolist (item rberenguel/gnus-notes-list)
      ;(message "%s %s %s" item (car item) (cdr item))
      (if (equal (car item) (format "%s" msgid))
          (progn
            (setq gnus-note (format "\t NOTE: %s" (cdr item)))
            (return))
        ))
    gnus-note
    ))
      
(defun rberenguel/gnus-notes ()
  (interactive)
  ;(message "%s" (car (gnus-summary-work-articles 1)))
  (setq rberenguel/gnus-notes-msgid (car (gnus-summary-work-articles 1)))
  (setq rberenguel/got-message (rberenguel/get-gnus-notes rberenguel/gnus-notes-msgid))
  ;(message "Got Message? %s"  rberenguel/got-message)
  (if (or (equal rberenguel/got-message nil) (equal rberenguel/got-message ""))
      (progn 
        (set-buffer (find-file-noselect rberenguel/gnus-notes-file))
        (setq gnus-new-note (read-from-minibuffer "Enter Note: "))
        (end-of-buffer)
        (newline-and-indent)
        (insert (concat (format "%s" rberenguel/gnus-notes-msgid) "," gnus-new-note))
        (save-buffer)
        (kill-buffer)
        (rberenguel/load-gnus-notes)
        (gnus-summary-prepare)
        ;(gnus-summary-rescan-group)
    )))
Written by Ruben Berenguel