Mail

This file holds all my settings for mu4e. This can get a little hairy.

Machine-specific stuff

Some stuff that can be overwritten in the emacs-site file for different machines, if necessary. There is also some user-specific stuff in the Account management section.

(defvar do.mail.queue/queue-dir "~/.emacs.d/mail_queue"
  "The path to the mail queue directory")

(defvar do.mail.queue/msmtp-bin "msmtp"
  "The path to the msmtp binary")

(defvar do.mail/nerd-fonts-fancy nil
  "If `t', enable mu4e fancy chars using glyphs from a 'nerd-fonts' patched font.")

General settings

;; load contrib functions
(require 'mu4e-contrib)

;; mu4e customizations/settings
(setq
 mu4e-attachment-dir "~/Downloads"
 mu4e-view-show-images t
 mu4e-compose-signature-auto-include nil
 mu4e-view-show-addresses 't
 mu4e-decryption-policy t
 mu4e-compose-dont-reply-to-self t
 mu4e-hide-index-messages t
 mu4e-main-buffer-hide-personal-addresses t
 mu4e-sent-folder "/Sent"
 mu4e-drafts-folder "/Drafts"
 mu4e-trash-folder "/Trash")

;; non-mu4e customizations
(setq
 mail-user-agent 'mu4e-user-agent
 message-kill-buffer-on-exit t
 mml-secure-openpgp-sign-with-sender t ; sign based on from field
 message-citation-line-format "On %a %d %b %Y at %R UTC%z, %f wrote:\n"
 message-citation-line-function #'message-insert-formatted-citation-line)

;; disable sending of format=flowed messages
(setq fill-flowed-encode-column 66)
(setq mml-enable-flowed nil)
(setq mu4e-compose-format-flowed nil)
(setq use-hard-newlines nil)

;; enable display of format=flowed messages
(setq mm-fill-flowed t)
(setq fill-flowed-display-column 120)

;; enable spell check
(add-hook 'mu4e-compose-mode-hook #'flyspell-mode)

Appearance

;; visual-line-mode while composing and viewing
(defun do.mail/turn-on-visual-line-mode ()
  (auto-fill-mode -1)
  (visual-line-mode))
(add-hook 'mu4e-compose-mode-hook #'do.mail/turn-on-visual-line-mode)
(add-hook 'mu4e-view-mode-hook #'do.mail/turn-on-visual-line-mode)

;; disable trailing whitespace
(add-hook 'mu4e-org-mode-hook     #'no-trailing-whitespace)
(add-hook 'mu4e-main-mode-hook    #'no-trailing-whitespace)
(add-hook 'mu4e-compose-mode-hook #'no-trailing-whitespace)
(add-hook 'mu4e-view-mode-hook    #'no-trailing-whitespace)
(add-hook 'mu4e-headers-mode-hook #'no-trailing-whitespace)

;; fancy characters from nerd-fonts
(when do.mail/nerd-fonts-fancy
  (setq mu4e-use-fancy-chars t)
  (setq mu4e-headers-attach-mark    `("a" . ,(char-to-string #xf8e1)))
  (setq mu4e-headers-unread-mark    `("u" . ,(char-to-string #xf6ef)))
  (setq mu4e-headers-flagged-mark   `("F" . ,(char-to-string #xf73a)))
  (setq mu4e-headers-seen-mark      `("S" . ,(char-to-string #xfaee)))
  (setq mu4e-headers-draft-mark     `("D" . ,(char-to-string #xf0f6)))
  (setq mu4e-headers-new-mark       `("N" . ,(char-to-string #x26a1)))
  (setq mu4e-headers-passed-mark    `("P" . ,(char-to-string #xf064)))
  (setq mu4e-headers-replied-mark   `("R" . ,(char-to-string #xf112)))
  (setq mu4e-headers-trashed-mark   `("T" . ,(char-to-string #xf014)))
  (setq mu4e-headers-encrypted-mark `("x" . ,(char-to-string #xf023)))
  (setq mu4e-headers-signed-mark    `("s" . ,(char-to-string #xf040))))

;; default face manipulations
(set-face-underline 'mu4e-header-highlight-face nil)
(set-face-bold      'mu4e-header-highlight-face nil)
(set-face-bold      'mu4e-flagged-face nil)
(set-face-bold      'mu4e-unread-face nil)

;; serif font for e-mails
(defun do.mail/toggle-serif-quiet ()
  "Toggle serif font without making noise"
  (toggle-serif nil))
(add-hook 'mu4e-compose-mode-hook #'do.mail/toggle-serif-quiet)
(add-hook 'mu4e-view-mode-hook #'do.mail/toggle-serif-quiet)

;; remove the list id from mu4e-headers
(setq mu4e-headers-fields
      '((:human-date . 12)
        (:flags . 6)
        (:from . 22)
        (:subject)))

Mail view settings

As of <2020-04-22 Wed>, the gnus view mode is still considered a "tech preview" in mu4e. this section configures it. we can easily turn it off.

;; enable the gnus message view "tech preview"
(setq mu4e-view-use-gnus t)

;; define some faces
(defface do.mail.faces/attachment-name nil
  "Custom face for attachment header name in gnus view buffers.")
(defface do.mail.faces/attachment-content nil
  "Custom face for attachments in gnus view buffers.")
(defface do.mail.faces/from-name nil
  "Custom face for 'From' header name in gnus view buffers.")
(defface do.mail.faces/from-content nil
  "Custom face for 'From' header content in gnus view buffers.")
(defface do.mail.faces/subject-name nil
  "Custom face for 'Subject' header name in gnus view buffers.")
(defface do.mail.faces/subject-content nil
  "Custom face for 'Subject' header content in gnus view buffers.")
(defface do.mail.faces/generic-name nil
  "Custom face for generic header name in gnus view buffers.")
(defface do.mail.faces/generic-content nil
  "Custom face for generic header content in gnus view buffers.")

;; header names
(do.faces/set-face-attrs-global 'do.mail.faces/from-name
 (list :foreground "#92a65e" :weight 'bold))
(do.faces/set-face-attrs-global 'do.mail.faces/subject-name
 (list :foreground "#92a65e" :weight 'bold))
(do.faces/set-face-attrs-global 'do.mail.faces/generic-name
 (list :foreground "#92a65e" :weight 'bold))
(do.faces/set-face-attrs-global 'do.mail.faces/attachment-name
 (list :foreground "orange red" :weight 'bold))

;; header content
(do.faces/set-face-attrs-global 'do.mail.faces/generic-content
 (list :inherit 'gnus-header-content))

(do.faces/set-face-attrs-global 'do.mail.faces/from-content
 (list :foreground "gold" :weight 'bold))

(do.faces/set-face-attrs-global 'do.mail.faces/subject-content
 (list :foreground "green" :weight 'bold :slant 'italic))

(do.faces/set-face-attrs-global 'do.mail.faces/attachment-content
 (list :slant 'italic :foreground "light coral"))

;; configure all header faces
(setq gnus-header-face-alist
      '(;; custom
        ("From" do.mail.faces/from-name do.mail.faces/from-content)
        ("Subject" do.mail.faces/subject-name do.mail.faces/subject-content)
        ("Attachment" do.mail.faces/attachment-name do.mail.faces/attachment-content)
        ;; defaults
        ("Newsgroups:.*," nil gnus-header-newsgroups)
        ;; catch-all
        ("" do.mail.faces/generic-name do.mail.faces/generic-content)))

;; add mail faces to `do.faces/serif-preserve-defaults-list'
(dolist (face '(mu4e-header-key-face
                mu4e-header-value-face
                mu4e-link-face
                mu4e-contact-face
                mu4e-compose-separator-face
                mu4e-compose-header-face
                message-header-name
                message-header-to
                message-header-cc
                message-header-newsgroups
                message-header-xheader
                message-header-subject
                message-header-other
                gnus-header-name
                gnus-header-from
                gnus-header-subject
                gnus-header-content
                gnus-header-newsgroup
                do.mail.faces/attachment-name
                do.mail.faces/attachment-content
                do.mail.faces/from-name
                do.mail.faces/from-content
                do.mail.faces/generic-name
                do.mail.faces/generic-content
                do.mail.faces/subject-name
                do.mail.faces/subject-content))
  (add-to-list 'do.faces/serif-preserve-defaults-list face))

;; don't attempt to shoddily wrap long lines
(setq gnus-treat-fill-long-lines nil)
(setq gnus-treat-fill-article nil)

HTML E-mail

When we encounter an HTML message, we want to render it using shr. I personally prefer using the mu4e-action-view-as-pdf action, but I'm having a hard time using that action as my mu4e-html2text command. For now, I'll try to make the shr2text rendering a little more bearable.

(setq
 ;; always prefer plaintext
 mu4e-view-prefer-html nil
 ;; mu4e-view-html-plaintext-ratio-heuristic most-positive-fixnum ; doesn't work well
 ;; if only html, render in emacs
 mu4e-html2text-command 'mu4e-shr2text
 shr-color-visible-luminance-min 80)

;; when using the gnus viewer, we need to set this instead
(setq mm-discouraged-alternatives '("text/html" "text/richtext" "image/.*"))

The PDF rendering feature was nice, but depended on compiling webkitgtk, which takes hours. Switched to using the wkhtmltopdf arch linux package for now

(defun do.mail.html/render-pdf (msg)
  "Attempt to render body of MSG as PDF and display in current buffer."
  (let ((msg2pdf (executable-find "wkhtmltopdf"))
        (buf (get-buffer-create "*rendered mail*"))
        (tmpfile (make-temp-file "pdfmailrender")))
    (unless msg2pdf
      (mu4e-error "wkhtmltopdf not found"))
    (unless (mu4e-message-has-field msg :body-html)
      (mu4e-error "message has no html."))
    ;; convert message body to PDF
    (with-temp-buffer
      (insert (mu4e-message-field msg :body-html))
      (shell-command-on-region
       (point-min) (point-max)
       (concat msg2pdf " -s Letter --quiet - "
               tmpfile
               " 2>/dev/null") nil nil nil nil nil))
    ;; display in current window
    (switch-to-buffer buf)
    (read-only-mode -1)
    (erase-buffer)
    (insert-file-contents tmpfile)
    (doc-view-mode)
    (delete-file tmpfile)))

One nice way to view HTML mail is using KMail's viewer mode. This pops up a lightweight GUI window showing just the rendered E-Mail. We add this as an action.

(defun do.mail/open-with-kmail (msg)
  "Open the message MSF with the KMail Viewer."
  (let ((path (expand-file-name (mu4e-message-field msg :path))))
    (start-process "*KMail Viewer*" nil shell-file-name shell-command-switch
                   (format "nohup 1>/dev/null 2>/dev/null kmail --view \"%s\""
                           path))))

Modify the default message view actions.

(add-to-list 'mu4e-view-actions
  '("ViewInBrowser" . mu4e-action-view-in-browser) t)

(add-to-list 'mu4e-view-actions
             '("PDFRender" . do.mail.html/render-pdf))

(add-to-list 'mu4e-view-actions
             '("KMail Viewer" . do.mail/open-with-kmail))

(add-to-list 'mu4e-headers-actions
             '("KMail Viewer" . do.mail/open-with-kmail))

(delete '("view as pdf" . mu4e-action-view-as-pdf) mu4e-view-actions)

Mail reception & sync

I originally used OfflineIMAP to sync mail from my mail servers to my local Maildir. This worked for a while, but unfortuantely OfflineIMAP is quite slow and buggy. During my big email overhauling, I ended up switching to the much faster (because it's written in C) mbsync. Every 2 minutes it syncs from and to my mail server.

During the next email overhaul, I switched to a set-up using imapnotify for IMAP IDLE push notifications. That changed a few things. The configuration for this is mostly contained in my dotfiles, so it won't show up much here. I still, however, periodically sync all mailboxes every once in a while.

(setq
 mu4e-get-mail-command "mbsync -aqV"
 mu4e-change-filenames-when-moving t
 mu4e-update-interval 300)

Mail transmission

I'm sending mail using msmtp, but just using msmtp as a replacement for sendmail has one major flaw: it is not asynchronous! Large messages will halt Emacs for a few uncomfortable seconds. Of course, this can be fixed with a few tricky lines of elisp.

I implemented a quick-and-dirty queuing system by saving messages to disk first instead of sending them. After a message is "sent" (or better: "put in the queue"), message-sent-hook calls function which starts asynchronous msmtp processes. Errors during transmission are being caught by a process sentinel that offers some options.

<2020-04-21 Tue> The above blurb was at least what I thought was going on when this was written. I feel like there are many problems with this approach and that I should revisit this soon. For now, I will re-name and re-structure this, but not change any functionality. But soon, this will all need a good re-write.

<2020-05-07 Thu> I have now replaced this with an even bigger hack that makes everything async. Consume at own risk.

This main code block pulls everything together.

;; need sendmail for this system
(require 'sendmail)
(require 'message)

;; load the queueing code with noweb
<<do.mail.queue/helpers>>
<<do.mail.queue/save-to-disk>>
<<do.mail.queue/send-from-disk>>
<<do.mail.queue/recover>>

;; as part of all this async stuff, I am taking care of moving the message to
;; Sent using my own code. here be dragons.
(setq mu4e-sent-messages-behavior 'delete)
(setq mu4e-sent-func #'do.mail/sent-mail-handler)

;; "send" mail by saving it on disk, then flush the queue -- we change the
;; keybinding later
(defun do.mail.queue/send-mail ()
  "Save a message to the queue and flush it. The saving is done either sync or async."
  (interactive)
  (if (not (do.mail.queue/mail-has-crypto-p))
      (do.mail.queue/async-save-to-disk-and-flush)
    ;; for sync transmission, we hack a few things.
    (let ((do.mail.queue.tmp/draft-path (buffer-file-name))
          (do.mail.queue.tmp/filename (mu4e~draft-message-filename-construct "S"))
          (do.mail.queue.tmp/sent-folder (concat (mu4e-root-maildir) (mu4e-get-sent-folder mu4e-compose-parent-message))))
      ;; call the regular `message-send-and-exit' function and then the async flush function.
      (setq message-send-mail-function do.mail.queue/sync-save-function)
      (setq message-sent-hook nil)
      (message-send-and-exit)
      (do.mail.queue/async-flush))))

;; we default to the synch version of `message-send-mail-function'. The async
;; one is called from the keybinding "C-c C-c"
(setq message-send-mail-function do.mail.queue/sync-save-function)

Helper functions

Now, some helper functions. The only interactive function is M-x mail-queue, which checks on the status of the queue.

(defun do.mail.queue/mail-queue-flush-maybe ()
  "Inform about the number of messages in the queue.

Ask to flush if queue non-empty."
  (interactive)
  (let ((queue-len (length (cdr (cdr (directory-files do.mail.queue/queue-dir))))))
    (if (zerop queue-len)
        (message "No messages in queue.")
      (when (y-or-n-p (format "%d messages in the queue. Flush? " queue-len))
        (do.mail.queue/async-flush)))))

(defun do.mail.queue/delete-mail (hash)
  "Delete msg HASH from queue."
  (delete-file
   (concat (file-name-as-directory do.mail.queue/queue-dir) hash)))

(defun do.mail/get-header-field-from-string (header field-name)
  "Extract the value of the header field FIELD-NAME from the e-mail header HEADER."
  (with-temp-buffer
    (insert header)
    (goto-char (point-min))
    (message-mode)
    (set-buffer-modified-p nil)
    (message-fetch-field field-name)))

(defun do.mail.queue/send-msg-callback (ret)
  "Clean up after we tried to send a message.

RET is a cons cell (STATUS . HASH), which indicates whether the
transmission was successful and also contains the hash of the
message. The states can be one of HASH-ERROR, SEND-ERROR or SUCCESS"
  (if (equal 'success (car ret))
      (do.mail.queue/successful (cdr ret))
    (do.mail.queue/unsuccessful (cdr ret))))

(defun do.mail.queue/async-flush (&optional retval)
  "Attempt to send every message in the queue asynchronously."
  ;; FIXME use the MATCHES argument?
  (let ((queue (cdr (cdr (directory-files do.mail.queue/queue-dir)))))
    (mapcar (lambda (hash)
              (async-start
               `(lambda ()
                  ,(async-inject-variables "do\\.mail\\.queue/.*")
                  (funcall do.mail.queue/send-function ,hash))
               #'do.mail.queue/send-msg-callback))
            queue)))

(defun do.mail.queue/mail-has-crypto-p ()
  (save-excursion
    (goto-char (point-min))
    (re-search-forward
     (concat "^" (regexp-quote mail-header-separator) "\n"
       "<#secure[^>]+\\(sign\\)?\\(encrypt\\)?")
     nil t)))

(defun do.mail/sent-mail-handler (docid path)
  "Do what the regular `mu4e-sent-func' does, but leave my buffers alone."
  (mu4e~compose-set-parent-flag path) ; in constrast to description, this works
  (when (file-exists-p path)
    (mu4e~proc-remove docid))
  (dolist (buf (buffer-list))
    (when (and (buffer-file-name buf)
               (string= (buffer-file-name buf) path))
      (kill-buffer buf))))

(setq do.mail.queue/load-msg-function
      (byte-compile
       (lambda (hash)
         "Return the message list for the message HASH"
         (let ((fn (concat (file-name-as-directory do.mail.queue/queue-dir) hash)))
           (when (file-exists-p fn)
             (read (with-temp-buffer (insert-file-contents fn) (buffer-string))))))))

;; why is my format for message-alist so strange? I cannot remember.
(setq do.mail.queue/gfa-function
      (byte-compile
       (lambda (key al)
         "Return KEY from alist AL."
         (car (cdr (assoc key al))))))

(setq do.mail.queue/concat-fn-function
      (byte-compile
       (lambda (sf fn) (concat sf "/cur/" fn))))

Save messages to disk

This function inserts a message into the queue by simply saving it to disk.

;; the save function depends on these dynamically bound vars -- this is a hack
(defvar do.mail.queue.tmp/sent-folder nil
  "A temp value passing the Sent folder to the sending function")
(defvar do.mail.queue.tmp/draft-path nil
  "A temp value passing the path of the draft email to the sending function")
(defvar do.mail.queue.tmp/filename nil
  "A temp value passing the filename of the Sent email to the sending function")

(defun do.mail.queue/async-save-to-disk-and-flush ()
  "Save the message in the current buffer to `do.mail.queue/queue-dir' asynchronously."

  ;; we put the mail in the sent folder manually in the callback.
  (let ((draft-path)
        (filename (mu4e~draft-message-filename-construct "S"))
        (sent-folder (concat (mu4e-root-maildir) (mu4e-get-sent-folder mu4e-compose-parent-message))))
    ;; prepare for sending
    (run-hooks 'message-send-hook) ; n.b.: this saves the draft, does a bunch of mu4e specific things.
    (setq draft-path (buffer-file-name))
    (unless (file-exists-p sent-folder)
      (mu4e~proc-mkdir sent-folder))
    ;; async send the message
    (async-start
     `(lambda ()
        (require 'sendmail)
        (require 'message)
        (with-temp-buffer
          (insert ,(buffer-substring-no-properties (point-min) (point-max)))
          (message-mode)
          ,(async-inject-variables "do\\.mail\\.queue/.*")
          ,(async-inject-variables "mml-.*\\|fill-flowed-encode-column")
          (setq do.mail.queue.tmp/sent-folder ,sent-folder)
          (setq do.mail.queue.tmp/draft-path ,draft-path)
          (setq do.mail.queue.tmp/filename ,filename)
          (setq message-send-mail-function ,do.mail.queue/sync-save-function)
          (message-send-and-exit)))
     ;; call the flush function when done processing
     #'do.mail.queue/async-flush)
    ;; finally get rid of the buffer when done
    (kill-buffer (current-buffer))))

(setq do.mail.queue/sync-save-function
      (byte-compile
       (lambda ()
         "Save the message to queue folder on disk"
         ;; first part taken from `message-send-mail-with-sendmail'
         (let ((case-fold-search t))
           (save-restriction
             (message-narrow-to-headers)
             (setq resend-to-addresses (message-fetch-field "resent-to")))
           ;; Change header-delimiter to be what sendmail expects.
           (goto-char (point-min))
           (re-search-forward
            (concat "^" (regexp-quote mail-header-separator) "\n"))
           (replace-match "\n")
           (backward-char 1)
           (setq delimline (point-marker))
           (run-hooks 'message-send-mail-hook)
           ;; Insert an extra newline if we need it to work around
           ;; Sun's bug that swallows newlines.
           (goto-char (1+ delimline))
           (when (eval message-mailer-swallows-blank-line)
             (newline))
           (let* ((coding-system-for-write message-send-coding-system)
                  (arglist (append
                            (list "-oi")
                            message-sendmail-extra-arguments
                            ;; Read the envelope-from address from the "from header"
                            (list "--read-envelope-from")
                            ;; These mean "report errors by mail"
                            ;; and "deliver in background".
                            (if (null message-interactive) '("-oem" "-odb"))
                            ;; Get the addresses from the message
                            ;; unless this is a resend.
                            ;; We must not do that for a resend
                            ;; because we would find the original addresses.
                            ;; For a resend, include the specific addresses.
                            (if resend-to-addresses
                                (list resend-to-addresses)
                              '("-t"))))
                  ;; Save the message and its arguments to sendmail as a file in
                  ;; `do.mail.queue/queue-dir'.
                  ;; The filename is the SHA256 hash of the
                  ;; message. We're saving an alist containing some info about message
                  (msg-body (buffer-substring-no-properties delimline (point-max)))
                  (msg-header (buffer-substring-no-properties (point-min) delimline))
                  (msg-hash (secure-hash 'sha256 (concat msg-header msg-body)))
                  (msg-alist
                   (list (cons 'args (list arglist))
                         (cons 'body (list msg-body))
                         (cons 'header (list msg-header))
                         (cons 'hash (list msg-hash))
                         (cons 'sent-folder (list do.mail.queue.tmp/sent-folder))
                         (cons 'draft-path (list do.mail.queue.tmp/draft-path))
                         (cons 'filename (list do.mail.queue.tmp/filename)))))
             (with-temp-buffer
               (goto-char (point-min))
               (insert (prin1-to-string msg-alist))
               (write-file (concat (file-name-as-directory do.mail.queue/queue-dir) msg-hash))))))))

Send messages from disk

To send a mail, we call do.mail.queue/send-function with the SHA256 hash of the message (which is also the filename) argument. This will prep the message for msmtp and pass it on to do.mail.queue/deliver-mail-function, which eventually calls msmtp.

(setq do.mail.queue/deliver-mail-function
        (byte-compile
         (lambda (sendmail-args msg hash sent-folder filename)
           "Attempt to Send the msg HASH using sendmail using the arguments SENDMAIL-ARGS.
  The message MSG is the *full* message, a concatenation of header and body."
           (require 'sendmail)
           (let ((mailfile (funcall do.mail.queue/concat-fn-function sent-folder filename)))
             (with-temp-buffer
               (insert msg)
               (write-file mailfile))
             (let ((ret (apply #'call-process do.mail.queue/msmtp-bin
                               mailfile nil nil
                               sendmail-args)))
               (if (equal 0 ret)
                   (cons 'success hash)
                 (delete-file mailfile)
                 (cons 'send-error hash)))))))

(setq do.mail.queue/send-function
      (byte-compile
       (lambda (hash)
         "Prep the message HASH for delivery and pass to `do.mail.queue/deliver-mail'."
         ;; load the message
         (let ((msg-alist (funcall do.mail.queue/load-msg-function hash))
               (gfa do.mail.queue/gfa-function))
           (let ((args        (funcall gfa 'args        msg-alist))
                 (body        (funcall gfa 'body        msg-alist))
                 (header      (funcall gfa 'header      msg-alist))
                 (saved-hash  (funcall gfa 'hash        msg-alist))
                 (sent-folder (funcall gfa 'sent-folder msg-alist))
                 (filename    (funcall gfa 'filename    msg-alist)))
             ;; check hash, if everything checks out, attempt to deliver
             (if (string= saved-hash (secure-hash 'sha256 (concat header body)))
                 (funcall do.mail.queue/deliver-mail-function
                          args (concat header body) hash sent-folder filename)
               ;; if hashes don't check out, something bad happened.
               (cons 'hash-error hash)))))))

Recover from send failures

If there is an error during the transmission, the callback defined in do.mail.queue/deliver-mail will then use the following two functions to query the user about how to continue.

(defun do.mail.queue/successful (hash)
  "Print a message in the minibuffer about a successful e-mail transmission."
  (let ((msg-alist (funcall do.mail.queue/load-msg-function hash))
        (gfa do.mail.queue/gfa-function))
    (let ((header      (funcall gfa 'header      msg-alist))
          (draft-path  (funcall gfa 'draft-path  msg-alist))
          (sent-folder (funcall gfa 'sent-folder msg-alist))
          (filename    (funcall gfa 'filename    msg-alist)))
      (let ((subject (s-truncate 20 (do.mail/get-header-field-from-string header "Subject") "..."))
            (to  (s-truncate 20 (do.mail/get-header-field-from-string header "To") "..."))
            (sent-path (funcall do.mail.queue/concat-fn-function sent-folder filename)))
        ;; add the sent message and notify mu that the draft corresponding to
        ;; the message was sent
        (mu4e~proc-add  sent-path)
        (mu4e~proc-sent draft-path)
        (mu4e-message "Successfully sent \"%s\" to %s." subject to))))
  (do.mail.queue/delete-mail hash))

(defun do.mail.queue/unsuccessful (hash)
  "Sending the mail HASH was unsuccessful. Query what to do next.

Query in the minibuffer the following 3 options:
  1) inspect message in new buffer
  2) delete message
  3) keep the message in the queue"
  (let* ((hash16 (substring-no-properties hash 0 16))
         (ret (do.mail.queue/fail-choices hash16)))
    (cond ((eq ret 'inspect) ;; use body of message in new message
           (let ((msg-alist (do.mail.queue/load-msg hash))
                 (buf (get-buffer-create "mail contents")))
             (pop-to-buffer buf)
             (insert (do.mail.queue/get-from-alist 'body msg-alist)))
           ;; delete old
           (do.mail.queue/delete-mail hash)
           (message "Deleted %s." hash16))
          ((eq ret 'delete) ;; delete the message
           (do.mail.queue/delete-mail hash)
           (message "Deleted %s." hash16))
          ((eq ret 'keep) ;; keep message in queue
           (message "Keeeping %s in queue." hash16)))))

(defun do.mail.queue/fail-choices (hash16)
  "Present User with choices for what to do next.

Return either DELETE, KEEP, or INSPECT, based on selection."
  (let ((correct-regexp "[dDkKiI]")
        (input "")
        (prompt (format
                 "Sending %s failed. [d]elete, [k]eep, [i]nspect body?: "
                 hash16)))
    (message "%s" prompt)
    (while (not (string-match correct-regexp input))
      (setq input (read-string prompt)))
    (cond ((string-match "[dD]" input) 'delete)
          ((string-match "[kK]" input) 'keep)
          ((string-match "[iI]" input) 'inspect))))

Contact management

(require 'subr-x)

;; use ivy for mu4e-commands
(setq mu4e-completing-read-function 'ivy-completing-read)

;; TODO: use org-contact to store contacts, then extract contact +
;; email address and append to the list that is passed to delete-dups
;; make sure mu4e contacts list is updated
(defun do.mail/get-all-contacts ()
  "Get a list of all e-mail contacts."
  (mu4e~request-contacts-maybe)
  (delq nil (delete-dups (hash-table-keys mu4e~contacts))))

(defun do.mail/ivy-get-contact (&optional start end)
  "Use ivy to get a mail contact. Optional arguments give a substring to prime ivy."
  (ivy-read "Contact: "
            (do.mail/get-all-contacts)
            :re-builder #'ivy--regex
            :sort nil
            :initial-input
            (if (and start end)
                (buffer-substring-no-properties start end)
              "")))

;; this is based on
;; http://pragmaticemacs.com/emacs/tweaking-email-contact-completion-in-mu4e/
(defun do.mail/ivy-select-and-insert-contact-header (&optional start)
  "Use ivy to select a contact from the mu4e database and insert into an e-mail header."
  (interactive)
  (let ((mail-abbrev-mode-regexp mu4e~compose-address-fields-regexp)
        (eoh ;; end-of-headers
         (save-excursion
           (goto-char (point-min))
           (search-forward-regexp mail-header-separator nil t))))
    (if (and eoh (> eoh (point)) (mail-abbrev-in-expansion-header-p))
        (let* ((end (point))
               (start
                (or start
                    (save-excursion
                      (re-search-backward "\\(\\`\\|[\n:,]\\)[ \t]*")
                      (goto-char (match-end 0))
                      (point))))
               (contact (do.mail/ivy-get-contact start end)))
          (unless (equal contact "")
            (kill-region start end)
            (insert contact)))
      (insert-tab))))

(defun do.mail/ivy-select-and-insert-contact-anywhere ()
  "Use ivy to select a contact from the mu4e database and insert anywhere."
  (interactive)
  (let ((contact (do.mail/ivy-get-contact)))
    (unless (string-equal contact "")
      (insert contact))))

Attachment management

Make the gnus-dired-mail-buffers function also work on message-mode derived modes, such as mu4e-compose-mode.

To attach file, just mark in dired and C-c RET C-a

(require 'gnus-dired)

(defun gnus-dired-mail-buffers ()
  "Return a list of active message buffers."
  (let (buffers)
    (save-current-buffer
      (dolist (buffer (buffer-list t))
  (set-buffer buffer)
  (when (and (derived-mode-p 'message-mode)
    (null message-sent-message-via))
    (push (buffer-name buffer) buffers))))
    (nreverse buffers)))

(setq gnus-dired-mail-mode 'mu4e-user-agent)

(add-hook 'dired-mode-hook 'turn-on-gnus-dired-mode)

Crypto and PGP

(defun do.mail/auto-encrypt-reply ()
  "Always encrypt when replying to an encrypted message"
  (let ((msg mu4e-compose-parent-message))
    (when msg
      (when (member 'encrypted (mu4e-message-field msg :flags))
        (mml-secure-message-encrypt-pgpmime)))))

(add-hook 'mu4e-compose-mode-hook #'do.mail/auto-encrypt-reply)

(defun do.mail/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.")))))

Emacs Aliases

Some aliases to make it easier to pop to the mail buffer and do other e-mail related stuff.

(defalias 'email 'mu4e)
(defalias 'em 'mu4e)

(defalias 'encrypt-mail 'mml-secure-message-encrypt-pgpmime)
(defalias 'sign-mail 'mml-secure-message-sign-pgpmime)

(defalias 'insert-contact 'do.mail/ivy-select-and-insert-contact-anywhere)
(defalias 'decrypt-inline-pgp 'do.mail/decrypt-inline-pgp)
(defalias 'mail-queue 'do.mail.queue/mail-queue-flush-maybe)

Account management

This section handles my own "sending identities" implementation. This is pre-mu4e-context stuff that still just does what I want it to do. No need for contexts.

Sending Identities

In this code block I define a list of sending identities that I want to be able to use. The table looks like the one below and of course the "real" table is not exported.

(defvar do.mail/sending-ids
  '(("[email protected]" . "Example User")
    ("[email protected]" . "Max Mustermann"))
  "An alist of (ADDRESS . FROM-NAME) e-mail identity pairs.")

;; set the default identity
(setq user-mail-address (caar do.mail/sending-ids))
(setq user-full-name (cdar do.mail/sending-ids))

Reply table

On some occasions, I want to force a reply to a message sent to address A to originate from address B. For this, I define the following table which maps A to B, where B must be a key in do.mail.accounts/sending-ids. This table looks like the one below and again, the "real" table is not exported. This is useful for mailing lists, when I subscribe to them with different addresses.

(defvar do.mail/reply-table
  '(("[email protected]" . "[email protected]")
    ("[email protected]" . "[email protected]"))
  "Force replies to messages adressed to A to be from B.")

Account selection logic

First some misc helper stuff.

;; when composing, we use ivy to read. this displays both the from address and
;; the full name during the selection.
(defun do.mail.accounts/ivy-display-xformer (addr)
  "A display transformer when selecting the from mail address ADDR."
  (let ((from-name (cdr (assoc addr do.mail/sending-ids)))
        (max-send-id-len (seq-max (mapcar (lambda (x) (length (car x))) do.mail/sending-ids))))
    (format (format "%%-%ds <%%s>" (1+ max-send-id-len)) addr from-name)))

;; register this transformer with ivy
(ivy-set-display-transformer 'do.mail.accounts #'do.mail.accounts/ivy-display-xformer)

;; put together the actual reply table as a hashmap
(defvar do.mail.accounts/reply-map (make-hash-table :test 'equal))
(mapcar (lambda (x) (puthash (car x) (car x) do.mail.accounts/reply-map))
        do.mail/sending-ids)
(mapcar (lambda (x) (puthash (car x) (cdr x) do.mail.accounts/reply-map))
        do.mail/reply-table)

(defun do.mail.accounts/get-mailing-list-addr (msg)
  "If MSG is from a GNU mailman mailing list, get the associated
address. I thought I needed this, but I don't think so
anymore. This can be all handled using the reply table."

  ;; TODO: could use this to do some generic heuristics, i.e., if its a list
  ;; that is @<university>.edu to choose the @<university>.edu e-mail address
  ;; or something.

  nil)

(defun do.mail.accounts/check-my-domains (msg)
  "Check to/cc/bcc for one of the domains in my domain list and
  return the corresponding address if found."

  ;; TODO: add a new variable for from *domains* and implement the logic. need
  ;; to change the set-id function for this as well for some defaults. for
  ;; another day.

  nil)

The code below is an attempt to a) select an e-mail address on compose and b) select the correct "from" address when replying to a message.

(defun do.mail.accounts/set-id (addr)
  "Set the sending identity to the id corresponding to ADDR."
  (setq user-mail-address addr)
  (setq user-full-name (cdr (assoc addr do.mail/sending-ids))))

(defun do.mail.accounts/set-id-new-mail ()
  "Prompt user for sending identity and set it."
  (do.mail.accounts/set-id
   (ivy-read "Compose as: " do.mail/sending-ids
             :caller 'do.mail.accounts :preselect 0)))

(defun do.mail.accounts/set-id-reply (parent-msg)
  "Figure out the right sending identity when replying to message PARENT-MSG."
  (let ((addr-from-reply-table)
        (addr-from-my-domains)
        (addr-from-reply-to-self)
        (addr-from-mailing-list))
    ;; is the To/Cc/Bcc (lol) address in my reply table?
    (maphash (lambda (k v)
               (when (mu4e-message-contact-field-matches
                      parent-msg '(:to :cc :bcc) k)
                 (setq addr-from-reply-table v)))
             do.mail.accounts/reply-map)
    ;; is the To/Cc/Bcc (lol) address from one of my domains? then just respond
    ;; in the same way as it was sent.
    (setq addr-from-my-domains (do.mail.accounts/check-my-domains parent-msg))
    ;; sometimes I "reply" to messages that were originally sent by
    ;; me. In this case, we want to extract the "from" address!
    (mapcar (lambda (x)
              (when (mu4e-message-contact-field-matches
                     parent-msg '(:from) (car x))
                (setq addr-from-reply-to-self (car x))))
            do.mail/sending-ids)
    ;; is this a mailing list message? -- this is deprecated
    (setq addr-from-mailing-list (do.mail.accounts/get-mailing-list-addr parent-msg))
    ;; make a decision. the default is the first entry of `do.mail/sending-ids'.
    (cond (addr-from-reply-table   (do.mail.accounts/set-id addr-from-reply-table))
          (addr-from-my-domains    (do.mail.accounts/set-id addr-from-my-domains))
          (addr-from-reply-to-self (do.mail.accounts/set-id addr-from-reply-to-self))
          (addr-from-mailing-list  (do.mail.accounts/set-id addr-from-mailing-list))
          (t                       (do.mail.accounts/set-id (caar do.mail/sending-ids))))))

(defun do.mail.accounts/set-from-id ()
  "Set the FROM address when composing a message."
  (if mu4e-compose-parent-message
      (do.mail.accounts/set-id-reply mu4e-compose-parent-message)
    (do.mail.accounts/set-id-new-mail)))

;; add the selection function to the relevant mu4e hook
(add-hook 'mu4e-compose-pre-hook #'do.mail.accounts/set-from-id)

Keybindings

Instead of quitting mu4e when q is pressed, I want it to keep running and just go back to the previously used buffer.

Other stuff is also dumped here.

;; keep mu4e running
(("C-c m" . #'mu4e) ; pop to mu4e with this global binding
 :map mu4e-main-mode-map
 ("q" . #'previous-buffer)
 :map mu4e-view-mode-map
 ;; open URLs with RET
 ("RET" . #'mu4e~view-browse-url-from-binding)
 ;; browse links in HTML e-mails
 ([tab] . #'shr-next-link)
 ([backtab] . #'shr-previous-link)
 ;; insert contacts with ivy
 :map mu4e-compose-mode-map
 ([tab] . #'do.mail/ivy-select-and-insert-contact-header)
 ("C-c C-c" . #'do.mail.queue/send-mail)
 )

Mail search

This is the only place where I use helm. It seems to work best for this task so far.

I am adding some customizations to not have the helm buffer take up the entire frame. The below block is a slightly patched version of the original helm-mu function.

(defun do.mail/hacked-helm-mu ()
  "A slightly modified version of `helm-mu'. This one doesn't take over the entire frame."
  (interactive)
  (let* ((query (if (and (eq major-mode 'mu4e-headers-mode)
                         (not helm-mu-always-use-default-search-string))
                    (mu4e-last-query)
                  helm-mu-default-search-string))
         ;; Do not append space it there is already trailing space or query is
         ;; empty
         (input (if (not (or (string-match-p " $" query)
                             (string= "" query)))
                    (concat query " ")
                  query))
         (helm-split-window-inside-p t)  ;; DO: added this
         (helm-display-header-line nil)) ;; DO: added this

    ;; If there is an existing helm action buffer kill it, otherwise it interferes
    ;; with the action for this source. This will happen if helm-mu is called as
    ;; an action from some other source
    (when (get-buffer helm-action-buffer)
      (kill-buffer helm-action-buffer))

    (helm :sources 'helm-source-mu
          :buffer "*mail search*" ;; DO: modified this
          :full-frame nil         ;; DO: modified this
          :keymap helm-mu-map
          :input input
          :candidate-number-limit 500)))

Then just enable helm and bind to "S" in all relevant places in mu4e.

(use-package helm-mu
  :ensure t
  :demand ;; one day I will grok use-package semantics and not have to force this.
  :after mu4e
  :init
  <<do.mail.helm/hacked-helm-mu>>
  (add-hook 'helm-major-mode-hook 'no-trailing-whitespace)
  :config
  ;; change the header face to something more noticable
  (do.faces/set-face-attrs-global
   'helm-source-header
   (list :foreground "gold" :weight 'bold :family "Fira Sans" :background nil))
  :bind (:map mu4e-main-mode-map ("S" . #'do.mail/hacked-helm-mu)
         :map mu4e-headers-mode-map ("S" . #'do.mail/hacked-helm-mu)))

Push notifications

I used to use the set up from this blog post to get email push notifications in Emacs. In short, this amounted to running a node.js program called imapnotify, which would open an IDLE connection and listen for updates. I then configured it to run a bash script which in turn ran mbsync and then some custom Python code to send a desktop notification and play some sound. imapnotify was monitored using prodigy.

This was all a little complex and involved a lot of moving parts. So I replaced it with my (first) Rust program called mail-notify, which does most of the work described above. (It still spawns mbsync as child process; I don't want to write my own IMAP sync code.)

mail-notify notifies the user of new mail using a standard desktop notification, but communicating this back to emacs is a little trickier. I use D-Bus for that, since there is a pretty good Rust library for it and once I figured out how, the Emacs support is pretty good.

The blocks below contain all of the code to make this happen from the Emacs side. We start with the two RPC calls I want to expose. This is a little hairy, but I guess it does what it's supposed to do…

(eval-when-compile (require 'dbus))

;; these are the functions we want to expose over dbus

(defun do.mail.ipc/handle-index-notify ()
  "Re-index the maildir."
  (message "Received reindex message on D-Bus...")
  (mu4e-update-index)
  (list :boolean t))

(defun do.mail.ipc/handle-header-refresh ()
  "Refresh the header buffer if it is currently visible."
  (message "Received refresh message on D-Bus...")
  (let* ((hdr-buf (get-buffer "*mu4e-headers*"))
         (hdr-visible (when hdr-buf (get-buffer-window hdr-buf t))))
    (when hdr-visible (mu4e-headers-rerun-search)))
 (list :boolean t))

;; to expose these functions, we define one d-bus "object" named mail with one
;; custom interface named "net.ogbe.emacs.mail" on the net.ogbe.emacs service

(defconst do.mail.ipc/service-name "net.ogbe.emacs")
(defconst do.mail.ipc/object-name "mail")
(defconst do.mail.ipc/custom-interface-name "net.ogbe.emacs.mail")

;; we save these to get around enabling lexical scoping... for now.
(defvar do.mail.ipc/root-xml nil)
(defvar do.mail.ipc/mail-xml nil)

;; we have to deal with a bunch of d-bus red tape to announce not only our
;; methods, but also introspection methods to the bus.

(defun do.mail.ipc/make-root-introspection-xml ()
  "Announce the object `do.mail.ipc/object-name' to the bus."
  (concat "<node name='/'>\n"
          "<interface name='org.freedesktop.DBus.Introspectable'>\n"
          "<method name='Introspect'>\n"
          "<arg name='xml_data' type='s' direction='out'/>\n"
          "</method>\n"
          "</interface>\n"
          (format "<node name='%s'>\n" do.mail.ipc/object-name)
          "</node>\n"
          "</node>"))

(defun do.mail.ipc/make-method-introspection-xml (methods)
  "Make the introspection XML for a list of methods."
  (let ((slist))
    ;; this path implements the "org.freedesktop.DBus.Introspectable" interface
    ;; which requires an implementation of the "Introspect" method which
    ;; returns XML data as string
    (push (format "<node name='/%s'>\n" do.mail.ipc/object-name) slist)
    (push (concat "<interface name='org.freedesktop.DBus.Introspectable'>\n"
                  "<method name='Introspect'>\n"
                  "<arg name='xml_data' type='s' direction='out'/>\n"
                  "</method>\n"
                  "</interface>\n")
          slist)
    ;; this path also implements the "net.ogbe.Emacs.MailRPC" interface
    (push (format "<interface name='%s'>\n" do.mail.ipc/custom-interface-name) slist)
    (dolist (method methods)
      ;; this assumes that all those methods have the same type signature of no
      ;; in arguments and one bool out argument (for this application I don't
      ;; need a type signature but I'll keep it just in case I need it at some
      ;; point in the future.
      (push (concat (format "<method name='%s'>\n" method)
                    "<arg name='' direction='out' type='b' />\n"
                    "</method>\n")
            slist))
    (push "</interface>\n" slist)
    (push "</node>" slist)
    (apply #'concat (nreverse slist))))

(defun do.mail.ipc/register-root-introspection ()
  "Register the introspection method of the root node"
  (dbus-register-method :session
                        do.mail.ipc/service-name
                        "/"
                        dbus-interface-introspectable
                        "Introspect"
                        #'(lambda () do.mail.ipc/root-xml)))

(defun do.mail.ipc/register-method-introspection ()
  "Register the introspection method announcing the existence of METHODS."
  (dbus-register-method :session
                          do.mail.ipc/service-name
                          (concat "/" do.mail.ipc/object-name)
                          dbus-interface-introspectable
                          "Introspect"
                          #'(lambda () do.mail.ipc/mail-xml)
                          t))

(defun do.mail.ipc/register-simple-method (method-name method-handler)
  "Register the method with the handler we want to call."
  (dbus-register-method :session
                        do.mail.ipc/service-name
                        (concat "/" do.mail.ipc/object-name)
                        do.mail.ipc/custom-interface-name
                        method-name
                        method-handler
                        t))

;; the next function registers all of our methods and their introspection
;; methods on the bus.

(defun do.mail.ipc/register-methods ()
  "Register my two mail RPC methods"
  ;; set up the xml strings
  (setq do.mail.ipc/root-xml (do.mail.ipc/make-root-introspection-xml))
  (setq do.mail.ipc/mail-xml (do.mail.ipc/make-method-introspection-xml '("reindex" "refresh")))
  ;; register the methods and their handlers
  (do.mail.ipc/register-simple-method "reindex" #'do.mail.ipc/handle-index-notify)
  (do.mail.ipc/register-simple-method "refresh" #'do.mail.ipc/handle-header-refresh)
  ;; register introspection for the two methods
  (do.mail.ipc/register-method-introspection)
  ;; register introspection for the root
  (do.mail.ipc/register-root-introspection))

(do.mail.ipc/register-methods)

The next block sets up prodigy to launch my mail-notify daemon. Since I chose to do all configuration via environment variables, I can configure it via elisp strings from right here.

(defvar do.mail.push/imap-host ""
  "The imap host for push notifications.")

(defvar do.mail.push/imap-port ""
  "The imap port for push notifications.")

(defvar do.mail.push/imap-user ""
  "The imap username for push notifications.")

(defvar do.mail.push/imap-passcmd ""
  "The command to run that returns the password for the imap server.")

(defvar do.mail.push/imap-mailbox ""
  "The imap mailbox to monitor for push notifications.")

(use-package prodigy
  :ensure t
  :init
  (prodigy-define-tag
    :name 'email
    :ready-message "Checking Email using IMAP IDLE. Ctrl-C to shutdown."
    :env `(("IMAP_HOST"    ,do.mail.push/imap-host)
           ("IMAP_PORT"    ,do.mail.push/imap-port)
           ("IMAP_USER"    ,do.mail.push/imap-user)
           ("IMAP_PASSCMD" ,do.mail.push/imap-passcmd)
           ("IMAP_MAILBOX" ,do.mail.push/imap-mailbox)
           ("RUST_LOG"     "trace")))
  (prodigy-define-service
    :name "mail-notify"
    :command "mail-notify"
    :tags '(email)
    :kill-signal 'sigkill)
  ;; autostart this
  (prodigy-start-service (car prodigy-services)))

Load & start mu4e

This just loads mu4e, applies all my custom code, and starts it. All the magic is happening in the sections above.

<<do.mail/per-machine-custom>>

(use-package mu4e
  :load-path "/usr/share/emacs/site-lisp/mu4e"
  :demand
  :config

  <<do.mail/mu4e-settings>>

  <<do.mail/appearance>>

  <<do.mail/gnus-view>>

  <<do.mail/mail-rx>>

  <<do.mail/mail-tx>>

  <<do.mail/contacts>>

  <<do.mail/attachments>>

  <<do.mail/html>>

  <<do.mail/crypto>>

  <<do.mail/aliases>>

  <<do.mail/mail-search>>

  <<do.mail/notify-dbus>>

  <<do.mail/notify-prodigy>>

  <<do.mail/accounts>>

  :bind
  <<do.mail/keys>>
  )

;; start mu4e at emacs startup
(mu4e t)

This is it!