Mu4e customization

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

Initialization

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-format-flowed 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-thread-child-prefix '("L " . "│ ")
      mu4e-headers-thread-connection-prefix '("| " . "│ ")
      mu4e-headers-thread-duplicate-prefix '("= " . "≡ ")
      mu4e-headers-thread-first-child-prefix '("L " . "⚬ ")
      mu4e-headers-thread-last-child-prefix '("L " . "└ ")
      mu4e-headers-time-format "%R"
      mu4e-hide-index-messages t
      ;;mu4e-html2text-command "html2text -utf8 -nobs -width 72"
      mu4e-html2text-command "w3m -dump -T text/html -cols 80 -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 gnus, mail, message or mm and have an effect on mu4e behavior.

(defvar ed/mu4e~original-message)

(defun ed/message-insert-citation-line (&optional from date _tz)
  (when ed/mu4e~original-message
    (unless date
      (setq date (mu4e-message-field ed/mu4e~original-message :date)))
    ;;(unless from
    ;;  (pcase-let ((`(,name . ,email) (car (mu4e-message-field ed/mu4e~original-message :from))))
    ;;    (setq from (format "%s <%s>" name email))))
    (message-insert-formatted-citation-line from date)))

(defun ed/mu4e-setup-original-message ()
  "Put `mu4e-compose-parent-message' in `ed/mu4e~original-message'.

Because the parent message is lost when it become usefull for inserting
citation line."
  (when (member mu4e-compose-type '(reply forward))
    (setq ed/mu4e~original-message mu4e-compose-parent-message)))

(defun ed/mu4e-reset-original-message ()
  "Reset `ed/mu4e~original-message' to avoid weird thing."
  (setq ed/mu4e~original-message nil))

(add-hook 'mu4e-compose-pre-hook #'ed/mu4e-setup-original-message)
(add-hook 'mu4e-compose-mode-hook #'ed/mu4e-reset-original-message)

(setq gnus-inhibit-images t
      gnus-article-date-headers 'user-defined
      gnus-article-time-format "%A %d %B %Y à %R"
      gnus-cite-attribution-suffix "\\(\\(a écrit[  ]?\\|wrote\\|writes\\|said\\|says\\|>\\)\\(:\\|\\.\\.\\.\\)\\|----- ?Original Message ?-----\\)[ \t]*$"
      gnus-header-face-alist '(("From" nil gnus-header-from)
                               ("Subject" nil gnus-header-subject)
                               ("Newsgroups:.*," nil gnus-header-newsgroups)
                               ("To:" nil gnus-header-from)
                               ("Flags:" nil font-lock-builtin-face)
                               ("" gnus-header-name gnus-header-content))
      gnus-sorted-header-list '("^From:" "^Reply-To:" "^To:" "^Cc:" "^Subject"
                                "^Date:" "^Flags:")
      mail-user-agent 'mu4e-user-agent
      message-cite-reply-position 'below
      message-citation-line-format "%a %d %b %Y à %R, %n a écrit :\n"
      message-citation-line-function #'ed/message-insert-citation-line
      message-kill-buffer-on-exit t
      message-send-mail-function 'smtpmail-send-it
      mm-discouraged-alternatives '("text/html"))

(defun ed/mm-inline-text-html-render-with-mu4e-html2text (handle)
  "Call `mu4e-message-body-text' to render HTML email hold by HANDLE in text."
  (mm-insert-inline
   handle
   ;; mu4e~view-message contain the currently viewed message plist, allowing
   ;; me to call this mu4e-only function.
   (mu4e-message-body-text mu4e~view-message nil)))

;; (with-eval-after-load 'mm-view
;;   (add-to-list 'mm-text-html-renderer-alist
;;                '(ed-mu4e-html2text . ed/mm-inline-text-html-render-with-mu4e-html2text)))
;; (setq mm-text-html-renderer 'ed-mu4e-html2text)

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)))))
(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 (equal key "From:")
                (equal 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)))

Save all attachments in a given directory

Mu4e only offer to save attachments one by one. This function allow one to save all attachments to a given directory. I just copied the mu4e-view-save-attachments function and generalized it.

(defun ed/mu4e-view-save-all-attachments (&optional arg)
  "Save all attachments of a given message.

If ARG is nil, all attachments will be saved in
`mu4e-attachment-dir'. When non-nil, user will be prompted to
choose a specific directory where to save all the files."
  (interactive "P")
  (when (and (eq major-mode 'mu4e-view-mode)
             (derived-mode-p 'gnus-article-mode))
    (let ((parts (mu4e--view-gather-mime-parts))
          (handles '())
          (files '())
          (directory (if arg
                         (read-directory-name "Save to directory: ")
                       mu4e-attachment-dir)))
      (dolist (part parts)
        (let ((fname (or (cdr (assoc 'filename (assoc "attachment" (cdr part))))
                         (cl-loop for item in part
                                  for name = (and (listp item) (assoc-default 'name item))
                                  thereis (and (stringp name) name)))))
          (when fname
            (push `(,fname . ,(cdr part)) handles)
            (push fname files))))
      (if files
          (cl-loop for (f . h) in handles
                   when (member f files)
                   do (mm-save-part-to-file
                       h (let ((file (expand-file-name f directory)))
                           (if (file-exists-p file)
                               (let (newname (count 1))
                                 (while (and
                                         (setq newname
                                               (format "%s-%s%s"
                                                       (file-name-sans-extension file)
                                                       count
                                                       (file-name-extension file t)))
                                         (file-exists-p newname))
                                   (cl-incf count))
                                 newname)
                             file))))
        (mu4e-message "No attached files found")))))
(define-key mu4e-view-mode-map "X" #'ed/mu4e-view-save-all-attachments)

Open an URL in a Firefox private window

Sometime, I want to open an external link in a private window of Firefox. I will reuse here my function ed/cleanup-url

(defun ed/mu4e-view-go-to-private-url (&optional multi)
  "Offer to go to url(s) in a private window of Firefox.

If MULTI (prefix-argument) is nil, go to a single one, otherwise,
offer to go to a range of urls."
  (interactive "P")
  (mu4e~view-handle-urls
   "URL to visit" multi
   (lambda (url)
     (start-process
      "private-firefox" nil
      "firefox" "--private-window" (ed/cleanup-url url)))))

(define-key mu4e-view-mode-map "G" #'ed/mu4e-view-go-to-private-url)

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 file name 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)
          (equal ed/signalspam-login "")
          (equal ed/signalspam-pass ""))
      (message "Please add your signal-spam login and password")
    (let ((path (or (mu4e-message-field msg :path) ""))
          (auth-token (base64-encode-string
                       (format "%s:%s"
                               ed/signalspam-login
                               (car (process-lines "pass" ed/signalspam-pass))))))
      (if (or (equal path "")
              (not (file-readable-p path)))
          (message "Mail file `%s' is not accessible" path)
        (with-temp-buffer
          (insert-file-contents path)
          (let ((url-request-method "POST")
                (url-request-extra-headers
                 `(("Content-Type" . "application/x-www-form-urlencoded")
                   ("Authorization" . ,(format "Basic %s" auth-token))))
                (url-request-data
                 (format "message=%s"
                         (base64-encode-string
                          (encode-coding-string
                           (buffer-string)
                           (select-safe-coding-system (point-min) (point-max)))
                          'no-line-break))))
            (url-retrieve
             "https://www.signal-spam.fr/api/signaler"
             ;; discard any result
             (lambda (_status) (kill-buffer (current-buffer)))
             nil t t))))))
  ;; In any case, mark it as deleted
  (when (eq major-mode 'mu4e-headers-mode)
    (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)

You also need to set the two variables ed/signalspam-login and ed/signalspam-pass to their correct values: ed/signalspam-login must contain your login email for signal-spam.fr, while ed/signalspam-pass must contain the name of the password-store file in which you have stored your password to connect to signal-spam.fr.

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 (equal 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 (equal xmailer useragent)
            xmailer
          (cond
           ((equal xmailer "") useragent)
           ((equal 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 (equal 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 (equal 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 (equal to "mailto")
      (setq to (pop data)))
    (dolist (item data)
      (cond ((equal "subject" item)
             (setq is-subject t))
            (is-subject
             (setq is-subject nil)
             (setq subject item))))
    (list to subject)))

(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
             '(:originctx . (:name "Message context"
                                   :shortname "Folder/List"
                                   :help "Origin account, folder and mailing-list information"
                                   :function (lambda (msg)
                                               ;; Prefer mu4e-message-field-raw over mu4e-message-field to be
                                               ;; sure to have nil when the header is absent.
                                               (let ((maildir (mu4e-message-field-raw msg :maildir))
                                                     (mailinglist (mu4e-message-field-raw msg :list))
                                                     shortaccount)
                                                 (when maildir
                                                   (setq shortaccount
                                                         (substring
                                                          ;; Extract account name from the first maildir component.
                                                          ;; i.e. "Posteo" in /Posteo/INBOX
                                                          (replace-regexp-in-string "^/\\(\\w+\\)/.*$" "\\1" maildir)
                                                          ;; Take only 3 first letter of it.
                                                          ;; i.e. "Pos" in Posteo
                                                          0 3))
                                                   ;; Keep only last part of maildir
                                                   (setq maildir (file-name-nondirectory maildir))
                                                   (if (> (length maildir) 8)
                                                       (setq maildir (concat (substring maildir 0 7) "…")))
                                                   (setq maildir (format "[%s]%s" shortaccount maildir)))
                                                 (when mailinglist
                                                   (setq maildir
                                                         (format "%s (%s)"
                                                                 maildir
                                                                 (mu4e-get-mailing-list-shortname mailinglist))))
                                                 maildir)))))

(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 '(:flags :maildir :mailing-list :tags :useragent :openpgp)
      mu4e-headers-fields '((:flags      . 5)
                            (:human-date . 12)
                            (:originctx  . 25)
                            (:from-or-to . 25)
                            ;(:size       . 6)
                            (:subject    . nil))
      mu4e-compose-hidden-headers '("^Face:" "^X-Face:" "^Openpgp:"
                                    "^X-Draft-From:" "^X-Mailer:"
                                    "^User-agent:"))

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