;;; rul-mail.el --- Email configuration ;; mml-sec.el ;; Use sender to find GPG key. (setq mml-secure-openpgp-sign-with-sender t) (use-package notmuch :ensure t :config ;; UI (setq notmuch-show-logo nil notmuch-column-control 1.0 notmuch-hello-auto-refresh t notmuch-hello-recent-searches-max 20 notmuch-hello-thousands-separator "" notmuch-show-all-tags-list t notmuch-show-text/html-blocked-images nil ) (setq notmuch-draft-folder "current/Drafts") ;; Keymaps (defun rul/capture-mail() "Capture mail to org mode." (interactive) (org-store-link nil) (org-capture nil "m") ) (bind-key "c" 'rul/capture-mail notmuch-show-mode-map) ;; Keep uppercase refresh cheap after tag operations; use M-g for a full poll. (define-key notmuch-show-mode-map "G" 'notmuch-refresh-this-buffer) (define-key notmuch-search-mode-map "G" 'notmuch-refresh-this-buffer) (define-key notmuch-show-mode-map (kbd "M-g") 'notmuch-poll-and-refresh-this-buffer) (define-key notmuch-search-mode-map (kbd "M-g") 'notmuch-poll-and-refresh-this-buffer) (define-key notmuch-show-mode-map "R" 'notmuch-show-reply) (define-key notmuch-search-mode-map "R" 'notmuch-search-reply-to-thread) ;; Spam (define-key notmuch-show-mode-map "S" (lambda () "mark message as spam" (interactive) (notmuch-show-tag (list "+spam" "-inbox" "-unread")))) (define-key notmuch-search-mode-map "S" (lambda (&optional beg end) "mark thread as spam" (interactive (notmuch-search-interactive-region)) (notmuch-search-tag (list "+spam" "-inbox" "-unread") beg end))) ;; Unspam: restore false-positives back to INBOX. Unlike archive, the file ;; move has to happen inline — the hook's path:current/Spam/** rule would ;; otherwise re-tag +spam on the next post-new, reverting the user's action. ;; Move the file BEFORE changing tags so an interleaved post-new from mbsync ;; can't re-apply +spam between our tag change and our rename. (defun rul/notmuch-move-to-inbox (query) "Move files matching QUERY that live under spam/trash maildirs into ~/mail/INBOX/cur/ with fresh uuid names. Reindex if any moved." (let ((inbox-cur (expand-file-name "~/mail/INBOX/cur/")) (spam-paths "(path:current/Spam/** or path:current/spam/** or path:current/Trash/** or path:current/trash/**)") (count 0)) ;; Spam/trash are in search.exclude_tags, so include excluded files here. (dolist (f (process-lines "notmuch" "search" "--exclude=false" "--output=files" (concat "(" query ") and " spam-paths))) (when (file-exists-p f) (let* ((base (file-name-nondirectory f)) (colon (cl-position ?: base :from-end t)) (suffix (if colon (substring base colon) "")) (uuid (string-trim (shell-command-to-string "uuidgen")))) (rename-file f (concat inbox-cur uuid suffix)) (cl-incf count)))) (when (> count 0) (call-process "notmuch" nil nil nil "new" "--no-hooks")) count)) (defun rul/notmuch-search-unspam (&optional beg end) "Restore threads from spam/trash: move files back to INBOX first (so a racing post-new can't re-tag +spam based on path), then tag -spam -trash -killed +inbox +unread, then refresh." (interactive (notmuch-search-interactive-region)) (let ((tids (save-excursion (goto-char beg) (cl-loop while (< (point) end) collect (notmuch-search-find-thread-id) do (forward-line 1))))) (dolist (tid tids) (rul/notmuch-move-to-inbox (concat "thread:" tid))) (notmuch-search-tag (list "-spam" "-trash" "-killed" "+inbox" "+unread") beg end)) (notmuch-refresh-this-buffer)) (defun rul/notmuch-show-unspam () "Restore current message from spam/trash back to INBOX." (interactive) (let ((mid (notmuch-show-get-message-id))) (rul/notmuch-move-to-inbox mid) (notmuch-show-tag (list "-spam" "-trash" "-killed" "+inbox" "+unread"))) (notmuch-refresh-this-buffer)) (define-key notmuch-search-mode-map "U" 'rul/notmuch-search-unspam) (define-key notmuch-show-mode-map "U" 'rul/notmuch-show-unspam) ;; Archive ;; Stock `a` applies notmuch-archive-tags but doesn't re-run the search, so ;; the thread stays visible in the tag:inbox buffer with stale results. Wrap ;; both `a` and `A` to refresh the buffer so the thread drops out of view. (setq notmuch-archive-tags (list "-inbox" "+archive")) (defun rul/notmuch-search-archive (&optional unarchive beg end) "Archive threads and refresh so they drop out of the inbox view." (interactive (cons current-prefix-arg (notmuch-search-interactive-region))) (notmuch-search-archive-thread unarchive beg end) (notmuch-refresh-this-buffer)) (defun rul/notmuch-show-archive () "Archive the current message with +archive -inbox -unread and refresh." (interactive) (notmuch-show-tag (list "+archive" "-inbox" "-unread")) (notmuch-refresh-this-buffer)) (define-key notmuch-search-mode-map "a" 'rul/notmuch-search-archive) (define-key notmuch-search-mode-map "A" 'rul/notmuch-search-archive) (define-key notmuch-show-mode-map "a" 'rul/notmuch-show-archive) (define-key notmuch-show-mode-map "A" 'rul/notmuch-show-archive) ;; Mark as read (define-key notmuch-search-mode-map "r" (lambda (&optional beg end) "mark thread as read" (interactive (notmuch-search-interactive-region)) (notmuch-search-tag (list "-unread") beg end) (notmuch-search-next-thread))) (define-key notmuch-search-mode-map (kbd "RET") (lambda () "Show the selected thread with notmuch-tree if it has more than one email. Use notmuch-show otherwise." (interactive) (if (= (plist-get (notmuch-search-get-result) :total) 1) (notmuch-search-show-thread) (notmuch-tree (notmuch-search-find-thread-id) notmuch-search-query-string nil (notmuch-prettify-subject (notmuch-search-find-subject)))))) (defun color-inbox-if-unread () (interactive) (save-excursion (goto-char (point-min)) (let ((cnt (car (process-lines "notmuch" "count" "tag:inbox and tag:unread")))) (when (> (string-to-number cnt) 0) (save-excursion (when (search-forward "inbox" (point-max) t) (let* ((overlays (overlays-in (match-beginning 0) (match-end 0))) (overlay (car overlays))) (when overlay (overlay-put overlay 'face '((:inherit bold) (:foreground "green"))))))))))) (defvar notmuch-hello-refresh-count 0) (defun notmuch-hello-refresh-status-message () (let* ((new-count (string-to-number (car (process-lines notmuch-command "count")))) (diff-count (- new-count notmuch-hello-refresh-count))) (cond ((= notmuch-hello-refresh-count 0) (message "You have %s messages." (notmuch-hello-nice-number new-count))) ((> diff-count 0) (message "You have %s more messages since last refresh." (notmuch-hello-nice-number diff-count))) ((< diff-count 0) (message "You have %s fewer messages since last refresh." (notmuch-hello-nice-number (- diff-count))))) (setq notmuch-hello-refresh-count new-count))) (add-hook 'notmuch-hello-refresh-hook 'color-inbox-if-unread) (add-hook 'notmuch-hello-refresh-hook 'notmuch-hello-refresh-status-message) (setq notmuch-hello-sections '(notmuch-hello-insert-saved-searches notmuch-hello-insert-search notmuch-hello-insert-recent-searches notmuch-hello-insert-alltags )) ;; https://git.sr.ht/~tslil/dotfiles/tree/4e51afbb/emacs/notmuch-config.el#L76-82 (defmacro make-binds (mode-map binds argfunc &rest body) "Create keybindings in `mode-map' using a list of (keystr . arg) pairs in `binds' of the form ( ... (argfunc arg) body)." `(progn ,@(mapcar (lambda (pair) `(define-key ,mode-map (kbd ,(car pair)) (lambda () (interactive) (,argfunc ,(cdr pair)) ,@body))) (eval binds)))) (defvar notmuch-hello-tree-searches '(("u" . "tag:unread") ("i" . "tag:inbox") ("*" . "*")) "List of (key . query) pairs to bind in notmuch-hello.") (make-binds notmuch-hello-mode-map notmuch-hello-tree-searches notmuch-search) ) ;; ends use-package notmuch (use-package notmuch-indicator :ensure t) (provide 'rul-mail)