Mu4e customization

This files holds all my customization made to the mu4e mail client.

Initialization

Autoload

mu4e autoload is desactivated, thus we have to deal with it ourselves.

(autoload 'mu4e "/usr/share/emacs/site-lisp/mu4e/mu4e.elc"
  "Start mu4e daemon and show its main window." t)

Then, for that magic to works, I associate the mu4e function to a keybinding, which will load it at first press.

(global-set-key (kbd "<f5>") #'mu4e)

Finally, I need to load the rest of the present file after mu4e has been loaded.

(with-eval-after-load 'mu4e
  ;; Only if mu4e.elc has not been already loaded
  (unless (fboundp 'ed/compose-new-mail)
    (let ((gc-cons-threshold most-positive-fixnum))
      (load-file (expand-file-name "mu4e.elc" user-emacs-directory)))))

Set Up variables

First, I set common variables. I made the choice to use an external call to w3m to render html email, instead of the default shr, as I find the result more usable.

(setq mu4e-attachment-dir "~/Téléchargements"
      mu4e-compose-complete-only-after "2012-01-01"
      mu4e-compose-context-policy 'ask
      mu4e-compose-crypto-reply-policy 'sign-and-encrypt
      mu4e-compose-dont-reply-to-self t
      mu4e-compose-reply-to-address nil ;; will be configured later by context
      mu4e-compose-signature nil ;; will be configured later by context
      mu4e-confirm-quit nil
      mu4e-context-policy 'pick-first
      mu4e-drafts-folder nil ;; will be configured later by context
      mu4e-get-mail-command "offlineimap -o -u quiet"
      mu4e-headers-date-format "%d/%m/%Y"
      mu4e-headers-include-related t
      mu4e-headers-skip-duplicates t
      mu4e-headers-time-format "%H:%M"
      mu4e-hide-index-messages t
      ;;mu4e-html2text-command "html2text -utf8 -nobs -width 72"
      mu4e-html2text-command "w3m -dump -T text/html -cols 72 -o display_link_number=true -o auto_image=false -o display_image=false -o ignore_null_img_alt=true"
      mu4e-split-view 'horizontal
      mu4e-trash-folder nil ;; will be configured later by context
      mu4e-update-interval 600 ;; in seconds or nil to desactivate
      mu4e-view-show-addresses t)

Some variables belong to the underlaying packages mail and message and have an effect on mu4e behavior.

(setq message-citation-line-format "%a %d %b %Y à %R, %n a écrit :\n"
      message-citation-line-function 'message-insert-formatted-citation-line
      message-kill-buffer-on-exit t
      message-send-mail-function 'smtpmail-send-it
      mail-user-agent 'mu4e-user-agent)

Passwords for the various SMTP servers are stored in a shared ~/.authinfo.gpg file.

(setq smtpmail-auth-credentials (expand-file-name "~/.authinfo.gpg"))

I also set my crypto choices via the following variables:

(setq mml-secure-openpgp-sign-with-sender t
      mml-secure-openpgp-encrypt-to-self t)

Configure Flyspell to avoid mail headers

(defun ed/flyspell-skip-mail-headers (begin _end _ignored)
  "Returns non-nil if BEGIN position is in mail header."
  (save-excursion
    (goto-char (point-min))
    (let ((end-header
           (re-search-forward "^--text follows this line--[[:space:]]*$" nil t)))
      (when end-header
        (< begin end-header)))))
(with-eval-after-load 'flyspell
  (add-hook 'flyspell-incorrect-hook #'ed/flyspell-skip-mail-headers))

Various utility functions

This section defines functions, which helps me in my email work flow.

Preview mail file in a new buffer

The first one may be called externally to display an email content when we know its file path.

(defun ed/preview-some-mail-at (path)
  (interactive "fPath: ")
  (call-process
   "mu" nil
   (switch-to-buffer (generate-new-buffer "*mail preview*") t)
   t "view" (expand-file-name path))
  (with-current-buffer "*mail preview*"
    (goto-char (point-min))
    (mu4e~fontify-cited)
    (mu4e~fontify-signature)
    (while (re-search-forward "^\\(\\w+:\\) \\(.*\\)$" nil t)
      (let ((key (match-string 1))
            (value (match-string 2)))
        (beginning-of-line)
        (delete-region (point) (line-end-position))
        (insert (concat (propertize key 'face 'mu4e-header-key-face) " "))
        (if (or (string= key "From:")
                (string= key "To:"))
            (insert (propertize value 'face 'mu4e-special-header-value-face))
          (insert (propertize value 'face 'mu4e-header-value-face)))))
    (forward-line)
    (beginning-of-line)
    (insert "\n")
    (read-only-mode)
    (local-set-key (kbd "q") #'kill-this-buffer)))

Display current mail in browser

The second function is called as a mu4e Action, to display the current email body in your external browser. This is useful when event W3M fails at rendering an HTML soup.

(defun ed/mu4e-msgv-action-view-in-browser (msg)
  "View the body of the message in a web browser."
  (interactive)
  (let ((html (mu4e-msg-field (mu4e-message-at-point t) :body-html))
        (tmpfile (format "%s/%d.html" temporary-file-directory (random))))
    (unless html (error "No html part for this message"))
    (with-temp-file tmpfile
      (insert
       "<html>"
       "<head><meta http-equiv=\"content-type\""
       "content=\"text/html;charset=UTF-8\">"
       html))
    (browse-url (concat "file://" tmpfile))))

(add-to-list 'mu4e-view-actions
             '("View in browser" . ed/mu4e-msgv-action-view-in-browser) t)

Notify current mail as a spam

The following function is called as a mu4e Action, to send it to the Signal Spam french service. If everything goes fine, then the message is marked for deletion.

(defcustom ed/signalspam-login nil
  "Login used to connect to the Signal Spam API."
  :type 'string
  :group 'mu4e)
(defcustom ed/signalspam-pass nil
  "Password used to connect to the Signal Spam API."
  :type 'string
  :group 'mu4e)
(defun ed/send-message-to-signal-spam (msg)
  (if (or (not ed/signalspam-login)
          (not ed/signalspam-pass)
          (string= ed/signalspam-login "")
          (string= ed/signalspam-pass ""))
      (message "Please add your signal-spam login and password")
    (let ((path (or (mu4e-message-field msg :path) "")))
      (if (or (string= path "")
              (not (file-readable-p path)))
          (message "Mail file `%s' is not accessible" path)
        (message
         (replace-regexp-in-string
          "[ \t\n]*$"
          ""
          (shell-command-to-string
           (format "/usr/bin/python3 %s \"%s\" \"%s\" \"%s\""
                   (expand-file-name "signalspam.py" package-user-dir)
                   ed/signalspam-login ed/signalspam-pass path)))))
      (when (eq major-mode 'mu4e-headers-mode)
        ;; in any case, mark it as deleted
        (mu4e-mark-set 'delete msg)
        (mu4e-headers-next)))))

(add-to-list 'mu4e-headers-actions
             '("Signal spam" . ed/send-message-to-signal-spam) t)
(add-to-list 'mu4e-view-actions
             '("Signal spam" . ed/send-message-to-signal-spam) t)

The previous function needs the following python code to run.

#!/usr/bin/env python3

import os
import sys
import requests
from base64 import b64encode


def submit_to_signal_spam(username, password, mail_path):
    if not os.path.isfile(mail_path):
        print("Mail non trouvé :/")
        return False
    mail_content = b""
    with open(mail_path, "rb") as f:
        mail_content = f.read()
    requests.post(
        "https://www.signal-spam.fr/api/signaler",
        auth=(username, password),
        data={"message": b64encode(mail_content)})
    print("Signalement ok")
    return True


if __name__ == "__main__":
    if len(sys.argv) != 4:
        print("Usage: {} LOGIN PASS MSG_PATH".format(sys.argv[0]))
        sys.exit(1)

    if submit_to_signal_spam(sys.argv[1], sys.argv[2], sys.argv[3]):
        sys.exit()
    sys.exit(1)

You also need to set the two variables ed/signalspam-login and ed/signalspam-pass to their correct values (the login and password of your Signal Spam account).

Extract a mail header by name for a given path

The first one extract one email header given by name and return its value as a string. It expects the sed command to be available on your system.

(defun ed/get-mail-header (header-name path)
  (replace-regexp-in-string
   "[ \t\n]*$"
   ""
   (shell-command-to-string
    (concat "sed -n '/^" header-name ":/I{:loop t;h;n;/^ /{H;x;s/\\n//;t loop};x;p}' '" path "' | sed -n 's/^" header-name ": \\(.*\\)$/\\1/Ip'"))))

On example of use of the previous function is the following, which extracts the email client name of your correspondant.

(defun ed/get-origin-mail-system-header (msg)
  (let ((path (or (mu4e-message-field msg :path) "")))
    (if (or (string= path "")
            (not (file-readable-p path)))
        "no path found"
      (let ((xmailer (ed/get-mail-header "x-mailer" path))
            (useragent (ed/get-mail-header "user-agent" path)))
        (if (string= xmailer useragent)
            xmailer
          (cond
           ((string= xmailer "") useragent)
           ((string= useragent "") xmailer)
           (t (concat xmailer " (xmailer)\n" useragent " (user-agent)"))))))))

GPG related functions

All the following functions interact with an email headers to expose or find out more about cryptography settings in use.

(defun ed/get-openpgp-header (msg)
  (let ((path (or (mu4e-message-field msg :path) "")))
    (if (or (string= path "")
            (not (file-readable-p path)))
        "Mail file is not accessible"
      (ed/get-mail-header "openpgp" path))))

(defcustom ed/gpg-pub-keys '()
  "List of GnuPG public keys used to sign or encrypt my email."
  :type '(alist :key-type (string :tag "mu4e context name")
                :value-type (string :tag "gpg mail header content"))
  :group 'mu4e-crypto)
(defun ed/insert-gpg-headers (sign-or-encrypt)
  (save-excursion
    (goto-char (point-min))
    (let ((pgp-info
           (cdr (assoc (mu4e-context-name (mu4e-context-current))
                       ed/gpg-pub-keys))))
      (when pgp-info
        (insert "Openpgp: " pgp-info)
        (if (string= sign-or-encrypt "encrypt")
            (mml-secure-message-sign-encrypt)
          (mml-secure-message-sign))))))

(defun ed/sign-this-message ()
  "Insert mml gpg command and gnupg header"
  (interactive)
  (ed/insert-gpg-headers "sign"))

(defun ed/encrypt-this-message ()
  "Insert mml gpg command and gnupg header"
  (interactive)
  (ed/insert-gpg-headers "encrypt"))

The last one is responsible to decypher old fashioned inline encyphered emails.

(defun ed/decrypt-inline-pgp ()
  "Decrypt a PGP MESSAGE block in the current buffer."
  (interactive)
  (save-excursion
    (let* ((pm (point-max))
           (beg (progn (re-search-forward "^-----BEGIN PGP MESSAGE-----$" pm t 1)
                       (match-beginning 0)))
           (end (re-search-forward "^-----END PGP MESSAGE-----$" pm t 1)))
      (if (and beg end)
          (epa-decrypt-region beg end)
        (message "No encrypted region found.")))))

(add-to-list 'mu4e-view-actions
             '("Decrypt inline PGP" . ed/decrypt-inline-pgp) t)

Get the signature for a given email address

This function returns a signature string (or an empty one) if the given address match one of the signature paths configuration.

(defun ed/get-signature-for (email-addr)
  "Return the right signature for the given EMAIL-ADDR.

The signatures must be stored in a list named `ed/signature-paths-list'."
  (let ((sig-path
         (cdr (assoc email-addr ed/signature-paths-list))))
    (unless sig-path
      (setq sig-path ed/default-signature-path))
    (with-temp-buffer
      (insert-file-contents sig-path)
      (buffer-string))))

Here is an exemple of ed/signature-paths-list list:

Open compose window from an external call

These functions allow me to open the compose window, with recipients and subject extracted from the given mailto: string.

(defun ed/parse-mailto-string (mailto-string)
  (let ((data (split-string mailto-string "[ :?&=]"))
        to is-subject subject)
    (setq to (pop data))
    (when (string= to "mailto")
      (setq to (pop data)))
    (dolist (item data)
      (cond ((string= "subject" item)
             (setq is-subject t))
            (is-subject
             (setq is-subject nil)
             (setq subject item))))
    (list to subject)))

(defun ed/quick-mu4e-pong-handler (data)
  "Handle 'pong' responses from the mu server."
  (setq mu4e~server-props (plist-get data :props)) ;; save info from the server
  (let ((doccount (plist-get mu4e~server-props :doccount)))
    (mu4e~check-requirements)))

(defun ed/quick-mu4e-start ()
  "Quickly start mu4e, avoiding as trouble as possible."
  ;; Load a context as soon as possible to avoid error messages about
  ;; missing folders
  (mu4e~context-autoswitch nil mu4e-context-policy)
  (setq mu4e-pong-func #'(lambda (info) (ed/quick-mu4e-pong-handler info)))
  (mu4e~proc-ping))

(defun ed/compose-new-mail (mailto-string)
  "Compose a new mail with metadata extracted from MAILTO-STRING."
  (ed/quick-mu4e-start)
  (let* ((mailto (ed/parse-mailto-string mailto-string))
         (to (car mailto))
         (subject (cadr mailto)))
    (mu4e~compose-mail to subject)))

The following function setup Emacs as a possible email client.

(defun ed/setup-emacs-mailto-target-desktop-file ()
  "Write xdg desktop file allowing emacs to be used as mailto target."
  (interactive)
  (with-temp-file
      (expand-file-name "~/.local/share/applications/emacsmail.desktop")
    (insert "[Desktop Entry]
Name=Compose message in Emacs
GenericName=Compose a new message with Mu4e in Emacs
Comment=Open mu4e compose window
MimeType=x-scheme-handler/mailto;
Exec=emacs -l /usr/share/emacs/site-lisp/mu4e/mu4e.elc --eval '(ed/compose-new-mail \"%u\")'
Icon=emacs
Type=Application
Terminal=false
Categories=Network;Email;
StartupWMClass=Emacs
"))
  (with-temp-buffer
    (call-process "update-desktop-database" nil (current-buffer) nil
                  (expand-file-name "~/.local/share/applications"))
    (buffer-string)))

Customize mail list view columns and message view headers

I try to reproduce as much as possible the vintage Alpine mail client look and feel.

(add-to-list 'mu4e-header-info-custom
             '(:acctshortname . (:name "Account short name"
                                       :shortname "Acct"
                                       :help "3 first letter of related root maildir"
                                       :function (lambda (msg)
                                                   (let ((account-name (or (mu4e-message-field msg :maildir) "")))
                                                     (if (string= account-name "")
                                                         ""
                                                       (substring
                                                        (replace-regexp-in-string "^/\\(\\w+\\)/.*$" "\\1" account-name)
                                                        0 3)))))))
(add-to-list 'mu4e-header-info-custom
             '(:foldername . (:name "Folder information"
                                    :shortname "Folder"
                                    :help "Message short storage information"
                                    :function (lambda (msg)
                                                (let ((shortaccount)
                                                      (maildir (or (mu4e-message-field msg :maildir) ""))
                                                      (mailinglist (or (mu4e-message-field msg :mailing-list) "")))
                                                  (if (not (string= mailinglist ""))
                                                      (setq mailinglist (mu4e-get-mailing-list-shortname mailinglist)))
                                                  (when (not (string= maildir ""))
                                                    (setq shortaccount
                                                          (substring
                                                           (replace-regexp-in-string "^/\\(\\w+\\)/.*$" "\\1" maildir)
                                                           0 3))
                                                    (setq maildir (replace-regexp-in-string ".*/\\([^/]+\\)$" "\\1" maildir))
                                                    (if (> (length maildir) 8)
                                                        (setq maildir (concat (substring maildir 0 7) "…")))
                                                    (setq maildir (concat "[" shortaccount "]" maildir)))
                                                  (cond
                                                   ((and (string= maildir "")
                                                         (not (string= mailinglist "")))
                                                    mailinglist)
                                                   ((and (not (string= maildir ""))
                                                         (string= mailinglist ""))
                                                    maildir)
                                                   ((and (not (string= maildir ""))
                                                         (not (string= mailinglist "")))
                                                    (concat maildir " (" mailinglist ")"))
                                                   (t
                                                    "")))))))

(add-to-list 'mu4e-header-info-custom
             '(:useragent . (:name "User-Agent"
                                   :shortname "UserAgt."
                                   :help "Mail client used by correspondant"
                                   :function ed/get-origin-mail-system-header)))
(add-to-list 'mu4e-header-info-custom
             '(:openpgp . (:name "PGP Info"
                                 :shortname "PGP"
                                 :help "OpenPGP information found in mail header"
                                 :function ed/get-openpgp-header)))
(setq mu4e-view-fields '(:from :to  :cc :subject :flags :date :maildir
                               :mailing-list :tags :useragent :attachments
                               :openpgp :signature :decryption)
      mu4e-headers-fields '((:flags         . 5)
                            (:human-date    . 12)
                            ;(:acctshortname . 4)
                            (:foldername    . 25)
                            (:from-or-to    . 25)
                            ;(:size          . 6)
                            (:subject       . nil))
      mu4e-compose-hidden-headers '("^Face:" "^X-Face:" "^Openpgp:"
                                    "^X-Draft-From:" "^X-Mailer:"
                                    "^User-agent:"))

Something relatively ugly is the visible trailing whitespaces. We can locally remove it with this.

(add-hook 'mu4e-headers-mode-hook
          #'(lambda () (setq-local show-trailing-whitespace nil)))

Add some magic to the default behaviors

Fix message width to 72 chars and expose mu4e in the mail headers when the compose window is opened. Also add local keybinding for GPG related operations (see ). Start flyspell mode in the compose window too.

(add-hook 'mu4e-compose-mode-hook
          #'(lambda ()
              (set-fill-column 72)
              (save-excursion
                (save-restriction
                  (message-narrow-to-headers)
                  (message-add-header
                    (concat "X-Mailer: mu4e " mu4e-mu-version
                            "; emacs " emacs-version "\n"))))
              (run-with-idle-timer 2 nil #'(lambda ()
                (let ((gc-cons-threshold most-positive-fixnum))
                  ;(flyspell-mode)
                  (flycheck-mode))))))
(add-hook 'mu4e-view-mode-hook #'mu4e-view-fill-long-lines)
(define-key mu4e-compose-mode-map (kbd "C-c <return> C-s") #'ed/sign-this-message)
(define-key mu4e-compose-mode-map (kbd "C-c <return> C-e") #'ed/encrypt-this-message)

Automatically add GPG headers if necessary when answering an email (for exemple to sign-encrypt an answer if the original message was encyphered).

;; Waiting for mu4e to be shipped with this
(add-hook 'mu4e-compose-mode-hook
          #'(lambda ()
              (let ((msg mu4e-compose-parent-message))
                (when msg
                  (cond
                   ((member 'encrypted (mu4e-message-field msg :flags))
                    (ed/encrypt-this-message))
                   ((member 'signed (mu4e-message-field msg :flags))
                    (ed/sign-this-message)))))))
(add-hook 'mu4e-compose-mode-hook #'epa-mail-mode)
(add-hook 'mu4e-view-mode-hook #'epa-mail-mode)

I don't like the trash system. So I just mark messages for deletion and remap keys to expose this feature as much as possible.

(define-key mu4e-headers-mode-map (kbd "<backspace>") #'mu4e-headers-mark-for-delete)
(define-key mu4e-headers-mode-map (kbd "d") #'mu4e-headers-mark-for-delete)
(define-key mu4e-headers-mode-map (kbd "D") #'mu4e-headers-mark-for-trash)

Announce

Now time to announce that my own customizations are ready.

(provide 'ed/mu4e)