mu4e-conversation.el 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  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-my-name "Me")
  35. (defvar mu4e-conversation--thread-headers nil)
  36. (defvar mu4e-conversation--thread nil)
  37. (defvar mu4e-conversation--current-message nil)
  38. (defvar mu4e-conversation-print-message-function 'mu4e-conversation-print-message
  39. "Function that takes a message and insert it's content in the current buffer.
  40. The second argument is the message index in
  41. `mu4e-conversation--thread', counting from 0.")
  42. (defgroup mu4e-conversation nil
  43. "Settings for the mu4e conversation view."
  44. :group 'mu4e)
  45. (defface mu4e-conversation-unread
  46. '((t :weight bold))
  47. "Face for unread messages."
  48. :group 'mu4e-conversation)
  49. (defface mu4e-conversation-sender-me
  50. '((t :inherit default))
  51. "Face for conversation message sent by yourself."
  52. :group 'mu4e-conversation)
  53. (defface mu4e-conversation-sender-1
  54. `((t :foreground ,(face-foreground 'outline-1)))
  55. "Face for conversation message from the 1st sender who is not yourself."
  56. :group 'mu4e-conversation)
  57. (defface mu4e-conversation-sender-2
  58. `((t :foreground ,(face-foreground 'outline-2)))
  59. "Face for conversation message from the 2rd sender who is not yourself."
  60. :group 'mu4e-conversation)
  61. (defface mu4e-conversation-sender-3
  62. `((t :foreground ,(face-foreground 'outline-3)))
  63. "Face for conversation message from the 3rd sender who is not yourself."
  64. :group 'mu4e-conversation)
  65. (defface mu4e-conversation-sender-4
  66. `((t :foreground ,(face-foreground 'outline-4)))
  67. "Face for conversation message from the 4th sender who is not yourself."
  68. :group 'mu4e-conversation)
  69. (defface mu4e-conversation-sender-5
  70. `((t :foreground ,(face-foreground 'outline-5)))
  71. "Face for conversation message from the 5th sender who is not yourself."
  72. :group 'mu4e-conversation)
  73. (defface mu4e-conversation-sender-6
  74. `((t :foreground ,(face-foreground 'outline-6)))
  75. "Face for conversation message from the 6th sender who is not yourself."
  76. :group 'mu4e-conversation)
  77. (defface mu4e-conversation-sender-7
  78. `((t :foreground ,(face-foreground 'outline-7)))
  79. "Face for conversation message from the 7th sender who is not yourself."
  80. :group 'mu4e-conversation)
  81. (defface mu4e-conversation-sender-8
  82. `((t :foreground ,(face-foreground 'outline-8)))
  83. "Face for conversation message from the 8th sender who is not yourself."
  84. :group 'mu4e-conversation)
  85. (defface mu4e-conversation-header
  86. '((t :foreground "grey70" :background "grey25"))
  87. "Face for conversation message sent by someone else."
  88. :group 'mu4e-conversation)
  89. (defcustom mu4e-conversation-max-colors -1
  90. "Max number of colors to use to colorize sender messages.
  91. If 0, don't use colors.
  92. If less than 0, don't limit the number of colors."
  93. :type 'integer
  94. :group 'mu4e-conversation)
  95. (defcustom mu4e-conversation-mode-map
  96. (let ((map (make-sparse-keymap)))
  97. (define-key map (kbd "[") 'mu4e-conversation-previous-message)
  98. (define-key map (kbd "]") 'mu4e-conversation-next-message)
  99. (define-key map (kbd "V") 'mu4e-conversation-toggle-view)
  100. (define-key map (kbd "q") 'mu4e-conversation-quit)
  101. ;; TODO: Should we reply to the selected message or to the last? Make it an option: 'current, 'last, 'ask.
  102. ;; TODO: Binding to switch to regular view?
  103. ;; TODO: Bind "#" to toggle-cite.
  104. ;; TODO: Bind "h" to show-html?
  105. map)
  106. "Map for `mu4e-conversation-mode'."
  107. :type 'key-sequence
  108. :group 'mu4e-conversation)
  109. (define-minor-mode mu4e-conversation-mode
  110. "Minor mode for `mu4e-conversation' buffers."
  111. :keymap mu4e-conversation-mode-map)
  112. (defun mu4e-conversation-previous-message ()
  113. "Go to previous message in linear view."
  114. (interactive)
  115. (mu4e-conversation-next-message -1))
  116. (defun mu4e-conversation-next-message (&optional count)
  117. "Go to next message in linear view.
  118. With numeric prefix argument or if COUNT is given, move that many
  119. messages. A negative COUNT goes backwards."
  120. (interactive "p")
  121. (when (eq major-mode 'org-mode)
  122. (user-error "Not in linear view."))
  123. (let ((move-function (if (< count 0)
  124. 'previous-char-property-change
  125. 'next-char-property-change)))
  126. (setq count (abs count))
  127. (dotimes (_ count)
  128. (while (and (goto-char (funcall move-function (point)))
  129. (not (eq (get-text-property (point) 'face) 'mu4e-conversation-header))
  130. (not (eobp)))))))
  131. (defun mu4e-conversation-quit ()
  132. "Quit conversation window."
  133. (interactive)
  134. (unless (eq major-mode 'mu4e-view-mode)
  135. (mu4e-view-mode))
  136. (mu4e~view-quit-buffer))
  137. (defun mu4e-conversation-toggle-view ()
  138. "Switch between tree and linear view."
  139. (interactive)
  140. (mu4e-conversation-show
  141. (if (eq major-mode 'org-mode)
  142. 'mu4e-conversation-print-message
  143. 'mu4e-conversation-print-org-message)))
  144. (defun mu4e-conversation-show (&optional print-function)
  145. "Display the thread in the `mu4e-conversation--buffer-name' buffer."
  146. ;; See the docstring of `mu4e-message-field-raw'.
  147. (switch-to-buffer (get-buffer-create mu4e~view-buffer-name))
  148. (view-mode 0)
  149. (erase-buffer)
  150. (let ((current-message-pos 0)
  151. (index 0))
  152. (dolist (msg mu4e-conversation--thread)
  153. (when (= (mu4e-message-field msg :docid)
  154. (mu4e-message-field mu4e-conversation--current-message :docid))
  155. (setq current-message-pos (point)))
  156. (funcall (or print-function
  157. mu4e-conversation-print-message-function)
  158. index)
  159. (mu4e~view-show-images-maybe msg)
  160. (goto-char (point-max))
  161. (insert (propertize "\n" 'msg msg)) ; Insert a final newline after potential images.
  162. (mu4e~view-mark-as-read-maybe msg)
  163. (setq index (1+ index))
  164. (goto-char (point-max)))
  165. (goto-char current-message-pos)
  166. (recenter))
  167. (mu4e~view-make-urls-clickable) ; TODO: Don't discard sender face.
  168. (setq header-line-format (propertize
  169. (mu4e-message-field (car mu4e-conversation--thread) :subject)
  170. 'face 'bold))
  171. (view-mode 1)
  172. (mu4e-conversation-mode))
  173. (defun mu4e-conversation--get-message-face (index)
  174. "Map 'from' addresses to 'sender-N' faces in chronological
  175. order and return corresponding face for e-mail at INDEX in
  176. `mu4e-conversation--thread'.
  177. E-mails whose sender is in `mu4e-user-mail-address-list' are skipped."
  178. (let* ((message (nth index mu4e-conversation--thread))
  179. (from (car (mu4e-message-field message :from)))
  180. ;; The e-mail address is not enough as key since automated messaging
  181. ;; system such as the one from github have the same address with
  182. ;; different names.
  183. (sender-key (concat (car from) (cdr from)))
  184. (sender-faces (make-hash-table :test 'equal))
  185. (face-index 1))
  186. (dotimes (i (1+ index))
  187. (let* ((msg (nth i mu4e-conversation--thread))
  188. (from (car (mu4e-message-field msg :from)))
  189. (sender-key (concat (car from) (cdr from)))
  190. (from-me-p (member (cdr from) mu4e-user-mail-address-list)))
  191. (unless (or from-me-p
  192. (gethash sender-key sender-faces))
  193. (when (or (not (facep (intern (format "mu4e-conversation-sender-%s" face-index))))
  194. (< 0 mu4e-conversation-max-colors face-index))
  195. (setq face-index 1))
  196. (puthash sender-key
  197. (intern (format "mu4e-conversation-sender-%s" face-index))
  198. sender-faces)
  199. (setq face-index (1+ face-index)))))
  200. (gethash sender-key sender-faces)))
  201. (defun mu4e-conversation-print-message (index)
  202. "Insert formatted message found at INDEX in `mu4e-conversation--thread'."
  203. ;; See the docstring of `mu4e-message-field-raw'.
  204. (unless (eq major-mode 'mu4e-view-mode)
  205. (mu4e-view-mode)
  206. (read-only-mode 0)) ; TODO: Set inhibit-read-only to t instead?
  207. (let* ((msg (nth index mu4e-conversation--thread))
  208. (from (car (mu4e-message-field msg :from)))
  209. (from-me-p (member (cdr from) mu4e-user-mail-address-list))
  210. (sender-face (or (get-text-property (point) 'face)
  211. (and from-me-p 'mu4e-conversation-sender-me)
  212. (and (/= 0 mu4e-conversation-max-colors) (mu4e-conversation--get-message-face index))
  213. 'default)))
  214. (insert (propertize (format "%s, %s %s\n"
  215. (if from-me-p
  216. mu4e-conversation-my-name
  217. (format "%s <%s>" (car from) (cdr from)))
  218. (current-time-string (mu4e-message-field msg :date))
  219. (mu4e-message-field msg :flags))
  220. 'face 'mu4e-conversation-header
  221. 'msg msg)
  222. (or (mu4e~view-construct-attachments-header msg) "")
  223. ;; TODO: Add button to display trimmed quote.
  224. ;; TODO: `mu4e-compose-reply' does not work when point is at end-of-buffer.
  225. (let ((s (propertize (mu4e-message-body-text msg) 'msg msg)))
  226. (add-face-text-property 0 (length s) sender-face nil s)
  227. (when (memq 'unread (mu4e-message-field msg :flags))
  228. (add-face-text-property 0 (length s) 'mu4e-conversation-unread nil s))
  229. s))))
  230. (defun mu4e-conversation-print-org-message (index)
  231. "Insert formatted message found at INDEX in `mu4e-conversation--thread'."
  232. ;; See the docstring of `mu4e-message-field-raw'.
  233. (unless (eq major-mode 'org-mode)
  234. (insert "#+SEQ_TODO: UNREAD READ\n\n") ; TODO: Is it possible to set `org-todo-keywords' locally?
  235. (org-mode))
  236. (let* ((msg (nth index mu4e-conversation--thread))
  237. (msg-header (nth index mu4e-conversation--thread-headers))
  238. (from (car (mu4e-message-field msg :from)))
  239. (from-me-p (member (cdr from) mu4e-user-mail-address-list))
  240. (level (plist-get (mu4e-message-field msg-header :thread) :level))
  241. (org-level (make-string (1+ level) ?*)))
  242. (insert (format "%s %s%s, %s %s\n"
  243. org-level
  244. (if (memq 'unread (mu4e-message-field msg :flags))
  245. "UNREAD "
  246. "")
  247. (if from-me-p
  248. mu4e-conversation-my-name
  249. (format "%s <%s>" (car from) (cdr from)))
  250. (current-time-string (mu4e-message-field msg :date))
  251. (mu4e-message-field msg :flags))
  252. ;; TODO: Put quote in subsection / property?
  253. ;; Prefix "*" at the beginning of lines with a space to prevent them
  254. ;; from being interpreted as Org sections.
  255. (replace-regexp-in-string (rx line-start "*") " *"
  256. (mu4e-message-body-text msg))
  257. "\n")))
  258. (defun mu4e-conversation-view-handler (msg)
  259. "Handler function for displaying a message."
  260. (push msg mu4e-conversation--thread)
  261. (when (= (length mu4e-conversation--thread)
  262. (length mu4e-conversation--thread-headers))
  263. (advice-remove mu4e-view-func 'mu4e-conversation-view-handler)
  264. ;; Headers are collected in reverse order, let's order them.
  265. (setq mu4e-conversation--thread-headers (nreverse mu4e-conversation--thread-headers))
  266. (let ((viewwin (mu4e~headers-redraw-get-view-window)))
  267. (unless (window-live-p viewwin)
  268. (mu4e-error "Cannot get a conversation window"))
  269. (select-window viewwin))
  270. (mu4e-conversation-show)))
  271. (defun mu4e-conversation-header-handler (msg)
  272. "Store thread messages.
  273. The header handler is run for all messages before the found-handler.
  274. See `mu4e~proc-filter'"
  275. (push msg mu4e-conversation--thread-headers))
  276. (defun mu4e-conversation-erase-handler (&optional _msg)
  277. "Don't clear the header buffer when viewing.")
  278. (defun mu4e-conversation-found-handler (_count)
  279. (advice-remove mu4e-header-func 'mu4e-conversation-header-handler)
  280. (advice-remove mu4e-erase-func 'mu4e-conversation-erase-handler)
  281. (advice-remove mu4e-found-func 'mu4e-conversation-found-handler)
  282. ;; TODO: Check if current buffer is mu4e-headers?
  283. (setq mu4e-conversation--thread nil)
  284. (advice-add mu4e-view-func :override 'mu4e-conversation-view-handler)
  285. (dolist (msg mu4e-conversation--thread-headers)
  286. (let ((docid (mu4e-message-field msg :docid))
  287. ;; decrypt (or not), based on `mu4e-decryption-policy'.
  288. (decrypt
  289. (and (member 'encrypted (mu4e-message-field msg :flags))
  290. (if (eq mu4e-decryption-policy 'ask)
  291. (yes-or-no-p (mu4e-format "Decrypt message?")) ; TODO: Never ask?
  292. mu4e-decryption-policy))))
  293. (mu4e~proc-view docid mu4e-view-show-images decrypt))))
  294. ;;;###autoload
  295. (defun mu4e-conversation (&optional msg)
  296. (interactive)
  297. (setq mu4e-conversation--current-message (or msg (mu4e-message-at-point)))
  298. (unless mu4e-conversation--current-message
  299. (mu4e-warn "No message at point"))
  300. (setq mu4e-conversation--thread-headers nil)
  301. (advice-add mu4e-header-func :override 'mu4e-conversation-header-handler)
  302. (advice-add mu4e-erase-func :override 'mu4e-conversation-erase-handler)
  303. (advice-add mu4e-found-func :override 'mu4e-conversation-found-handler)
  304. (mu4e~proc-find
  305. (funcall mu4e-query-rewrite-function
  306. (format "msgid:%s" (mu4e-message-field
  307. mu4e-conversation--current-message
  308. :message-id)))
  309. 'show-threads
  310. :date
  311. 'ascending
  312. (not 'limited)
  313. 'skip-duplicates
  314. 'include-related))
  315. (provide 'mu4e-conversation)
  316. ;;; mu4e-conversation.el ends here