mu4e-conversation.el 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. ;;; mu4e-conversation.el --- Show a complete thread in a single buffer -*- lexical-binding: t -*-
  2. ;; Copyright (C) 2018 Pierre Neidhardt <ambrevar@gmail.com>
  3. ;; Author: Pierre Neidhardt <ambrevar@gmail.com>
  4. ;; Maintainer: Pierre Neidhardt <ambrevar@gmail.com>
  5. ;; URL: https://notabug.org/Ambrevar/mu4e-conversation
  6. ;; Version: 0.0.1
  7. ;; Package-Requires: ((emacs "25.1"))
  8. ;; Keywords: mail, convenience, mu4e
  9. ;; This file is not part of GNU Emacs.
  10. ;; This program is free software; you can redistribute it and/or modify
  11. ;; it under the terms of the GNU General Public License as published by
  12. ;; the Free Software Foundation, either version 3 of the License, or
  13. ;; (at your option) any later version.
  14. ;;
  15. ;; This program is distributed in the hope that it will be useful,
  16. ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
  17. ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  18. ;; GNU General Public License for more details.
  19. ;;
  20. ;; You should have received a copy of the GNU General Public License
  21. ;; along with this program. If not, see <http://www.gnu.org/licenses/>.
  22. ;;; Commentary:
  23. ;; In this file we define mu4e-conversation-mode (+ helper functions), which is
  24. ;; used for viewing all e-mail messages of a thread in a single buffer.
  25. ;; TODO: Overrides are not commended. Use unwind-protect to set handlers? I don't think it would work.
  26. ;; TODO: Only mark visible messages as read.
  27. ;; TODO: Indent user messages?
  28. ;; TODO: Detect subject changes.
  29. ;; TODO: Check out mu4e gnus view.
  30. ;;; Code:
  31. (require 'mu4e)
  32. (require 'rx)
  33. (require 'outline)
  34. (defvar mu4e-conversation--thread-headers nil)
  35. (defvar mu4e-conversation--thread nil)
  36. (defvar mu4e-conversation--current-message nil)
  37. (defvar mu4e-conversation-print-message-function 'mu4e-conversation-print-message-linear
  38. "Function that insert the formatted content of a message in the current buffer.
  39. The argument is the message index in `mu4e-conversation--thread',
  40. counting from 0.")
  41. (defgroup mu4e-conversation nil
  42. "Settings for the mu4e conversation view."
  43. :group 'mu4e)
  44. (defcustom mu4e-conversation-own-name "Me"
  45. "Name to display instead of your own name.
  46. This applies to addresses matching `mu4e-user-mail-address-list'.
  47. If nil, the name value is not substituted."
  48. :type 'string
  49. :group 'mu4e-conversation)
  50. (defface mu4e-conversation-unread
  51. '((t :weight bold))
  52. "Face for unread messages."
  53. :group 'mu4e-conversation)
  54. (defface mu4e-conversation-sender-me
  55. '((t :inherit default))
  56. "Face for conversation message sent by yourself."
  57. :group 'mu4e-conversation)
  58. (defface mu4e-conversation-sender-1
  59. `((t :foreground ,(face-foreground 'outline-1)))
  60. "Face for conversation message from the 1st sender who is not yourself."
  61. :group 'mu4e-conversation)
  62. (defface mu4e-conversation-sender-2
  63. `((t :foreground ,(face-foreground 'outline-2)))
  64. "Face for conversation message from the 2rd sender who is not yourself."
  65. :group 'mu4e-conversation)
  66. (defface mu4e-conversation-sender-3
  67. `((t :foreground ,(face-foreground 'outline-3)))
  68. "Face for conversation message from the 3rd sender who is not yourself."
  69. :group 'mu4e-conversation)
  70. (defface mu4e-conversation-sender-4
  71. `((t :foreground ,(face-foreground 'outline-4)))
  72. "Face for conversation message from the 4th sender who is not yourself."
  73. :group 'mu4e-conversation)
  74. (defface mu4e-conversation-sender-5
  75. `((t :foreground ,(face-foreground 'outline-5)))
  76. "Face for conversation message from the 5th sender who is not yourself."
  77. :group 'mu4e-conversation)
  78. (defface mu4e-conversation-sender-6
  79. `((t :foreground ,(face-foreground 'outline-6)))
  80. "Face for conversation message from the 6th sender who is not yourself."
  81. :group 'mu4e-conversation)
  82. (defface mu4e-conversation-sender-7
  83. `((t :foreground ,(face-foreground 'outline-7)))
  84. "Face for conversation message from the 7th sender who is not yourself."
  85. :group 'mu4e-conversation)
  86. (defface mu4e-conversation-sender-8
  87. `((t :foreground ,(face-foreground 'outline-8)))
  88. "Face for conversation message from the 8th sender who is not yourself."
  89. :group 'mu4e-conversation)
  90. (defface mu4e-conversation-header
  91. '((t :foreground "grey70" :background "grey25"))
  92. "Face for conversation message sent by someone else."
  93. :group 'mu4e-conversation)
  94. (defcustom mu4e-conversation-max-colors -1
  95. "Max number of colors to use to colorize sender messages.
  96. If 0, don't use colors.
  97. If less than 0, don't limit the number of colors."
  98. :type 'integer
  99. :group 'mu4e-conversation)
  100. ;; TODO: Maybe we need multiple maps: a common one, and one for each view.
  101. (defcustom mu4e-conversation-mode-map
  102. (let ((map (make-sparse-keymap)))
  103. (define-key map (kbd "[") 'mu4e-conversation-previous-message)
  104. (define-key map (kbd "]") 'mu4e-conversation-next-message)
  105. (define-key map (kbd "V") 'mu4e-conversation-toggle-view)
  106. (define-key map (kbd "q") 'mu4e-conversation-quit)
  107. ;; TODO: Should we reply to the selected message or to the last? Make it an option: 'current, 'last, 'ask.
  108. ;; TODO: Binding to switch to regular view?
  109. ;; TODO: Bind "#" to toggle-cite.
  110. ;; TODO: Bind "h" to show-html?
  111. map)
  112. "Map for `mu4e-conversation-mode'."
  113. :type 'key-sequence
  114. :group 'mu4e-conversation)
  115. (define-minor-mode mu4e-conversation-mode
  116. "Minor mode for `mu4e-conversation' buffers."
  117. :keymap mu4e-conversation-mode-map)
  118. (defun mu4e-conversation-previous-message ()
  119. "Go to previous message in linear view."
  120. (interactive)
  121. (mu4e-conversation-next-message -1))
  122. (defun mu4e-conversation-next-message (&optional count)
  123. "Go to next message in linear view.
  124. With numeric prefix argument or if COUNT is given, move that many
  125. messages. A negative COUNT goes backwards."
  126. (interactive "p")
  127. (when (eq major-mode 'org-mode)
  128. (user-error "Not in linear view."))
  129. (let ((move-function (if (< count 0)
  130. 'previous-char-property-change
  131. 'next-char-property-change)))
  132. (setq count (abs count))
  133. (dotimes (_ count)
  134. (while (and (goto-char (funcall move-function (point)))
  135. (not (eq (get-text-property (point) 'face) 'mu4e-conversation-header))
  136. (not (eobp)))))))
  137. (defun mu4e-conversation-quit ()
  138. "Quit conversation window."
  139. (interactive)
  140. (unless (eq major-mode 'mu4e-view-mode)
  141. (mu4e-view-mode))
  142. (mu4e~view-quit-buffer))
  143. (defun mu4e-conversation-toggle-view ()
  144. "Switch between tree and linear view."
  145. (interactive)
  146. (mu4e-conversation-show-thread
  147. (if (eq major-mode 'org-mode)
  148. 'mu4e-conversation-print-message-linear
  149. 'mu4e-conversation-print-message-tree)))
  150. (defun mu4e-conversation-show-thread (&optional print-function)
  151. "Display the thread in the `mu4e-conversation--buffer-name' buffer."
  152. ;; See the docstring of `mu4e-message-field-raw'.
  153. (switch-to-buffer (get-buffer-create mu4e~view-buffer-name))
  154. (view-mode 0)
  155. (erase-buffer)
  156. (let* ((current-message-pos 0)
  157. (index 0)
  158. (filter (lambda (seq) (if (eq mu4e-conversation-print-message-function 'mu4e-conversation-print-message-linear)
  159. ;; In linear view, it makes more sense to sort messages chronologically.
  160. (sort seq
  161. (lambda (msg1 msg2)
  162. (time-less-p (mu4e-message-field msg1 :date)
  163. (mu4e-message-field msg2 :date))))
  164. seq)))
  165. (mu4e-conversation--thread (funcall filter mu4e-conversation--thread))
  166. (mu4e-conversation--thread-headers (funcall filter mu4e-conversation--thread-headers)))
  167. (dolist (msg mu4e-conversation--thread)
  168. (when (= (mu4e-message-field msg :docid)
  169. (mu4e-message-field mu4e-conversation--current-message :docid))
  170. (setq current-message-pos (point)))
  171. (funcall (or print-function
  172. mu4e-conversation-print-message-function)
  173. index)
  174. (mu4e~view-show-images-maybe msg)
  175. (goto-char (point-max))
  176. (insert (propertize "\n" 'msg msg)) ; Insert a final newline after potential images.
  177. (mu4e~view-mark-as-read-maybe msg)
  178. (setq index (1+ index))
  179. (goto-char (point-max)))
  180. (goto-char current-message-pos)
  181. (recenter))
  182. (mu4e~view-make-urls-clickable) ; TODO: Don't discard sender face.
  183. (setq header-line-format (propertize
  184. (mu4e-message-field (car mu4e-conversation--thread) :subject)
  185. 'face 'bold))
  186. (view-mode 1)
  187. (mu4e-conversation-mode))
  188. (defun mu4e-conversation--get-message-face (index)
  189. "Map 'from' addresses to 'sender-N' faces in chronological
  190. order and return corresponding face for e-mail at INDEX in
  191. `mu4e-conversation--thread'.
  192. E-mails whose sender is in `mu4e-user-mail-address-list' are skipped."
  193. (let* ((message (nth index mu4e-conversation--thread))
  194. (from (car (mu4e-message-field message :from)))
  195. ;; The e-mail address is not enough as key since automated messaging
  196. ;; system such as the one from github have the same address with
  197. ;; different names.
  198. (sender-key (concat (car from) (cdr from)))
  199. (sender-faces (make-hash-table :test 'equal))
  200. (face-index 1))
  201. (dotimes (i (1+ index))
  202. (let* ((msg (nth i mu4e-conversation--thread))
  203. (from (car (mu4e-message-field msg :from)))
  204. (sender-key (concat (car from) (cdr from)))
  205. (from-me-p (member (cdr from) mu4e-user-mail-address-list)))
  206. (unless (or from-me-p
  207. (gethash sender-key sender-faces))
  208. (when (or (not (facep (intern (format "mu4e-conversation-sender-%s" face-index))))
  209. (< 0 mu4e-conversation-max-colors face-index))
  210. (setq face-index 1))
  211. (puthash sender-key
  212. (intern (format "mu4e-conversation-sender-%s" face-index))
  213. sender-faces)
  214. (setq face-index (1+ face-index)))))
  215. (gethash sender-key sender-faces)))
  216. (defun mu4e-conversation--from-name (message)
  217. "Return a string describing the sender (the 'from' field) of MESSAGE."
  218. (let* ((from (car (mu4e-message-field message :from)))
  219. (from-me-p (member (cdr from) mu4e-user-mail-address-list)))
  220. (if (and mu4e-conversation-own-name from-me-p)
  221. mu4e-conversation-own-name
  222. (concat (car from)
  223. (when (car from) " ")
  224. (format "<%s>" (cdr from))))))
  225. (defun mu4e-conversation-print-message-linear (index)
  226. "Insert formatted message found at INDEX in `mu4e-conversation--thread'."
  227. ;; See the docstring of `mu4e-message-field-raw'.
  228. (unless (eq major-mode 'mu4e-view-mode)
  229. (mu4e-view-mode)
  230. (read-only-mode 0)) ; TODO: Set inhibit-read-only to t instead?
  231. (let* ((msg (nth index mu4e-conversation--thread))
  232. (from (car (mu4e-message-field msg :from)))
  233. (from-me-p (member (cdr from) mu4e-user-mail-address-list))
  234. (sender-face (or (get-text-property (point) 'face)
  235. (and from-me-p 'mu4e-conversation-sender-me)
  236. (and (/= 0 mu4e-conversation-max-colors) (mu4e-conversation--get-message-face index))
  237. 'default)))
  238. (insert (propertize (format "%s, %s %s\n"
  239. (mu4e-conversation--from-name msg)
  240. (current-time-string (mu4e-message-field msg :date))
  241. (mu4e-message-field msg :flags))
  242. 'face 'mu4e-conversation-header
  243. 'msg msg)
  244. (or (mu4e~view-construct-attachments-header msg) "") ; TODO: Append newline?
  245. ;; TODO: Add button to display trimmed quote.
  246. ;; TODO: `mu4e-compose-reply' does not work when point is at end-of-buffer.
  247. (let ((s (propertize (mu4e-message-body-text msg) 'msg msg)))
  248. (add-face-text-property 0 (length s) sender-face nil s)
  249. (when (memq 'unread (mu4e-message-field msg :flags))
  250. (add-face-text-property 0 (length s) 'mu4e-conversation-unread nil s))
  251. s))))
  252. (defun mu4e-conversation-print-message-tree (index)
  253. "Insert formatted message found at INDEX in `mu4e-conversation--thread'."
  254. ;; See the docstring of `mu4e-message-field-raw'.
  255. (unless (eq major-mode 'org-mode)
  256. (insert "#+SEQ_TODO: UNREAD READ\n\n") ; TODO: Is it possible to set `org-todo-keywords' locally?
  257. (org-mode))
  258. (let* ((msg (nth index mu4e-conversation--thread))
  259. (msg-header (nth index mu4e-conversation--thread-headers))
  260. (from (car (mu4e-message-field msg :from)))
  261. (from-me-p (member (cdr from) mu4e-user-mail-address-list))
  262. (level (plist-get (mu4e-message-field msg-header :thread) :level))
  263. (org-level (make-string (1+ level) ?*)))
  264. (insert (format "%s %s%s, %s %s\n"
  265. org-level
  266. (if (memq 'unread (mu4e-message-field msg :flags))
  267. "UNREAD "
  268. "")
  269. (mu4e-conversation--from-name msg)
  270. (current-time-string (mu4e-message-field msg :date))
  271. (mu4e-message-field msg :flags))
  272. ;; TODO: Put quote in subsection / property?
  273. ;; Prefix "*" at the beginning of lines with a space to prevent them
  274. ;; from being interpreted as Org sections.
  275. (replace-regexp-in-string (rx line-start "*") " *"
  276. (mu4e-message-body-text msg))
  277. "\n")))
  278. (defun mu4e-conversation-view-handler (msg)
  279. "Handler function for displaying a message."
  280. (push msg mu4e-conversation--thread)
  281. (when (= (length mu4e-conversation--thread)
  282. (length mu4e-conversation--thread-headers))
  283. (advice-remove mu4e-view-func 'mu4e-conversation-view-handler)
  284. ;; Headers are collected in reverse order, let's order them.
  285. (setq mu4e-conversation--thread-headers (nreverse mu4e-conversation--thread-headers))
  286. (let ((viewwin (mu4e~headers-redraw-get-view-window)))
  287. (unless (window-live-p viewwin)
  288. (mu4e-error "Cannot get a conversation window"))
  289. (select-window viewwin))
  290. (mu4e-conversation-show-thread)))
  291. (defun mu4e-conversation-header-handler (msg)
  292. "Store thread messages.
  293. The header handler is run for all messages before the found-handler.
  294. See `mu4e~proc-filter'"
  295. (push msg mu4e-conversation--thread-headers))
  296. (defun mu4e-conversation-erase-handler (&optional _msg)
  297. "Don't clear the header buffer when viewing.")
  298. (defun mu4e-conversation-found-handler (_count)
  299. (advice-remove mu4e-header-func 'mu4e-conversation-header-handler)
  300. (advice-remove mu4e-erase-func 'mu4e-conversation-erase-handler)
  301. (advice-remove mu4e-found-func 'mu4e-conversation-found-handler)
  302. ;; TODO: Check if current buffer is mu4e-headers?
  303. (setq mu4e-conversation--thread nil)
  304. (advice-add mu4e-view-func :override 'mu4e-conversation-view-handler)
  305. (dolist (msg mu4e-conversation--thread-headers)
  306. (let ((docid (mu4e-message-field msg :docid))
  307. ;; decrypt (or not), based on `mu4e-decryption-policy'.
  308. (decrypt
  309. (and (member 'encrypted (mu4e-message-field msg :flags))
  310. (if (eq mu4e-decryption-policy 'ask)
  311. (yes-or-no-p (mu4e-format "Decrypt message?")) ; TODO: Never ask?
  312. mu4e-decryption-policy))))
  313. (mu4e~proc-view docid mu4e-view-show-images decrypt))))
  314. ;;;###autoload
  315. (defun mu4e-conversation (&optional msg)
  316. (interactive)
  317. (setq mu4e-conversation--current-message (or msg (mu4e-message-at-point)))
  318. (unless mu4e-conversation--current-message
  319. (mu4e-warn "No message at point"))
  320. (setq mu4e-conversation--thread-headers nil)
  321. (advice-add mu4e-header-func :override 'mu4e-conversation-header-handler)
  322. (advice-add mu4e-erase-func :override 'mu4e-conversation-erase-handler)
  323. (advice-add mu4e-found-func :override 'mu4e-conversation-found-handler)
  324. (mu4e~proc-find
  325. (funcall mu4e-query-rewrite-function
  326. (format "msgid:%s" (mu4e-message-field
  327. mu4e-conversation--current-message
  328. :message-id)))
  329. 'show-threads
  330. :date
  331. 'ascending
  332. (not 'limited)
  333. 'skip-duplicates
  334. 'include-related))
  335. (provide 'mu4e-conversation)
  336. ;;; mu4e-conversation.el ends here