aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-12 00:34:03 -0500
committerCraig Jennings <c@cjennings.net>2026-05-12 00:34:03 -0500
commit22232fc39598ffc065ee134889c3143566be5faa (patch)
treed72f4630ead87023b7e17ceeeabceb7db3f8b3c0
parentcd0b90dc74996f7bd7b897834bac7038ffb7f5b8 (diff)
downloaddotemacs-22232fc39598ffc065ee134889c3143566be5faa.tar.gz
dotemacs-22232fc39598ffc065ee134889c3143566be5faa.zip
refactor(mail): extract the mu4e attachment workflow into its own module
The attachment-save UI (the MIME-part filters, the three save commands, and the `special-mode'-derived selection buffer) was ~230 lines in `mail-config.el' and depended on nothing else there. It moves to `modules/mu4e-attachments.el', which `mail-config' now requires. `cj/email-map' and its C-; e bindings stay put. The keymap just points at commands that now live next door. The unit tests move with it: `test-mail-config-attachments.el' becomes `test-mu4e-attachments.el' and requires the new module directly instead of pulling in the whole mu4e and org-msg use-package stack. The two tests that check `cj/email-map' wiring move to a new `test-mail-config.el', since that map belongs to `mail-config'. One of the moved tests quietly relied on a real mu4e install (it loaded `mu4e-mime-parts' through a load-path entry that loading `mail-config' happens to add), so it now stubs that path itself.
-rw-r--r--modules/mail-config.el232
-rw-r--r--modules/mu4e-attachments.el245
-rw-r--r--tests/test-mail-config.el24
-rw-r--r--tests/test-mu4e-attachments.el (renamed from tests/test-mail-config-attachments.el)148
4 files changed, 341 insertions, 308 deletions
diff --git a/modules/mail-config.el b/modules/mail-config.el
index 9d428209..4dbd6fb2 100644
--- a/modules/mail-config.el
+++ b/modules/mail-config.el
@@ -7,10 +7,8 @@
;;
;; https://macowners.club/posts/email-emacs-mu4e-macos/
;;
-;; Attachment saving:
-;; - C-; e S saves all attachments from the current mu4e view message.
-;; - C-; e s prompts for one attachment and saves it.
-;; Both commands prompt for a destination directory.
+;; Attachment saving lives in the `mu4e-attachments' module; its C-; e
+;; bindings are wired into `cj/email-map' below.
;;
;; Crash Fix:
;; auto-composition-mode is disabled in mu4e-headers-mode to prevent a
@@ -22,7 +20,7 @@
(require 'user-constants)
(require 'system-lib)
-(require 'seq)
+(require 'mu4e-attachments)
;; cj/custom-keymap's real binding is in keybindings.el, which init.el loads
;; first. The use-package org-msg :preface below wraps in eval-and-compile, so
@@ -37,15 +35,6 @@
(defvar send-mail-function nil)
(defvar message-send-mail-function nil)
(defvar message-sendmail-envelope-from nil)
-(defvar mu4e-uniquify-save-file-name-function)
-(defvar-local cj/mu4e-attachment-selection-directory nil
- "Destination directory for the current attachment selection buffer.")
-(defvar-local cj/mu4e-attachment-selection-entries nil
- "Attachment selection entries for the current selection buffer.")
-
-(declare-function mm-save-part-to-file "mm-decode" (handle filename))
-(declare-function mu4e-join-paths "mu4e-helpers" (directory &rest components))
-(declare-function mu4e-view-mime-parts "mu4e-mime-parts" ())
(defcustom cj/smtpmail-debug-enabled nil
"Non-nil means enable verbose SMTP transport debug logging.
@@ -87,221 +76,6 @@ transport details in debug buffers."
message-sendmail-envelope-from 'header)
(setq sendmail-program nil)))
-;; --------------------------- Attachment Saving ------------------------------
-
-(defun cj/mu4e--attachment-parts (&optional parts)
- "Return attachment-like MIME PARTS for the current mu4e view message.
-When PARTS is nil, read parts from `mu4e-view-mime-parts'."
- (seq-filter (lambda (part) (plist-get part :attachment-like))
- (or parts
- (progn
- (unless (fboundp 'mu4e-view-mime-parts)
- (require 'mu4e-mime-parts))
- (mu4e-view-mime-parts)))))
-
-(defun cj/mu4e--attachment-duplicate-filenames (parts)
- "Return filenames that appear more than once in PARTS."
- (let ((counts (make-hash-table :test 'equal))
- duplicates)
- (dolist (part parts)
- (let ((filename (plist-get part :filename)))
- (puthash filename (1+ (gethash filename counts 0)) counts)))
- (maphash (lambda (filename count)
- (when (> count 1)
- (push filename duplicates)))
- counts)
- duplicates))
-
-(defun cj/mu4e--attachment-label (part duplicate-filenames)
- "Return a completion label for PART.
-DUPLICATE-FILENAMES is a list of filenames that need part-index disambiguation."
- (let ((filename (or (plist-get part :filename) "unnamed-attachment")))
- (if (member filename duplicate-filenames)
- (format "%s <part %s>" filename (plist-get part :part-index))
- filename)))
-
-(defun cj/mu4e--attachment-candidates (parts)
- "Return completion candidates for attachment PARTS.
-The result is an alist of display labels to MIME part plists."
- (let ((duplicates (cj/mu4e--attachment-duplicate-filenames parts)))
- (mapcar (lambda (part)
- (cons (cj/mu4e--attachment-label part duplicates) part))
- parts)))
-
-(defun cj/mu4e--attachment-default-directory (parts)
- "Return a sensible default save directory for attachment PARTS."
- (file-name-as-directory
- (or (plist-get (car parts) :target-dir)
- (expand-file-name "~/Downloads/"))))
-
-(defun cj/mu4e--read-attachment-directory (parts)
- "Prompt for a destination directory for attachment PARTS."
- (file-name-as-directory
- (read-directory-name "Save attachments to: "
- (cj/mu4e--attachment-default-directory parts))))
-
-(defun cj/mu4e--ensure-attachment-save-functions ()
- "Load mu4e MIME support when attachment save helpers need it."
- (unless (and (boundp 'mu4e-uniquify-save-file-name-function)
- (fboundp 'mu4e-join-paths))
- (require 'mu4e-mime-parts)))
-
-(defun cj/mu4e--save-attachment-part (part directory)
- "Save attachment PART to DIRECTORY and return the final path."
- (cj/mu4e--ensure-attachment-save-functions)
- (let ((handle (plist-get part :handle)))
- (unless handle
- (user-error "Attachment has no MIME handle: %s"
- (or (plist-get part :filename) "<unnamed>")))
- (let* ((path (funcall mu4e-uniquify-save-file-name-function
- (mu4e-join-paths directory
- (plist-get part :filename)))))
- (mm-save-part-to-file handle path)
- path)))
-
-(defun cj/mu4e--save-attachment-parts (parts directory)
- "Save attachment PARTS to DIRECTORY and return the saved paths."
- (mapcar (lambda (part)
- (cj/mu4e--save-attachment-part part directory))
- parts))
-
-(defun cj/mu4e-save-all-attachments ()
- "Prompt for a directory and save all attachments in the current mu4e message."
- (interactive)
- (let ((parts (cj/mu4e--attachment-parts)))
- (unless parts
- (user-error "No attachments for this message"))
- (let* ((directory (cj/mu4e--read-attachment-directory parts))
- (paths (cj/mu4e--save-attachment-parts parts directory)))
- (message "Saved %d attachment%s to %s"
- (length paths)
- (if (= (length paths) 1) "" "s")
- directory)
- paths)))
-
-(defun cj/mu4e-save-attachment-here ()
- "Prompt for one attachment and a directory, then save that attachment."
- (interactive)
- (let ((parts (cj/mu4e--attachment-parts)))
- (unless parts
- (user-error "No attachments for this message"))
- (let* ((directory (cj/mu4e--read-attachment-directory parts))
- (candidates (cj/mu4e--attachment-candidates parts))
- (choice (completing-read "Save attachment: " candidates nil t))
- (part (cdr (assoc choice candidates)))
- (path (cj/mu4e--save-attachment-part part directory)))
- (message "Saved attachment to %s" path)
- path)))
-
-(defvar cj/mu4e-attachment-selection-mode-map
- (let ((map (make-sparse-keymap)))
- (define-key map (kbd "RET") #'cj/mu4e-attachment-selection-toggle)
- (define-key map (kbd "a") #'cj/mu4e-attachment-selection-mark-all)
- (define-key map (kbd "u") #'cj/mu4e-attachment-selection-unmark-all)
- (define-key map (kbd "s") #'cj/mu4e-attachment-selection-save-marked)
- (define-key map (kbd "q") #'quit-window)
- map)
- "Keymap for `cj/mu4e-attachment-selection-mode'.")
-
-(define-derived-mode cj/mu4e-attachment-selection-mode special-mode "Mail Attachments"
- "Mode for selecting mu4e attachments to save.")
-
-(defun cj/mu4e--attachment-selection-entry-at-point ()
- "Return the attachment selection entry at point."
- (or (get-text-property (point) 'cj/mu4e-attachment-entry)
- (get-text-property (line-beginning-position) 'cj/mu4e-attachment-entry)
- (user-error "No attachment on this line")))
-
-(defun cj/mu4e--attachment-selection-render ()
- "Render the current attachment selection buffer."
- (let ((inhibit-read-only t)
- (point-line (line-number-at-pos)))
- (erase-buffer)
- (insert (format "Save attachments to: %s\n\n"
- cj/mu4e-attachment-selection-directory))
- (insert "RET toggle a mark all u unmark all s save marked q quit\n\n")
- (dolist (entry cj/mu4e-attachment-selection-entries)
- (let* ((part (plist-get entry :part))
- (mark (if (plist-get entry :selected) "[x]" "[ ]"))
- (label (plist-get entry :label))
- (mime-type (or (plist-get part :mime-type) ""))
- (size (if-let ((bytes (plist-get part :decoded-size-approx)))
- (file-size-human-readable bytes)
- "")))
- (insert
- (propertize
- (format "%s %-40s %-24s %s\n" mark label mime-type size)
- 'cj/mu4e-attachment-entry entry))))
- (goto-char (point-min))
- (forward-line (max 0 (1- point-line)))))
-
-(defun cj/mu4e--attachment-selection-setup (parts directory)
- "Populate the current selection buffer with attachment PARTS and DIRECTORY."
- (setq cj/mu4e-attachment-selection-directory directory)
- (setq cj/mu4e-attachment-selection-entries
- (mapcar (lambda (candidate)
- (list :label (car candidate)
- :part (cdr candidate)
- :selected nil))
- (cj/mu4e--attachment-candidates parts)))
- (cj/mu4e--attachment-selection-render))
-
-(defun cj/mu4e-attachment-selection-toggle ()
- "Toggle the attachment entry at point."
- (interactive)
- (let ((entry (cj/mu4e--attachment-selection-entry-at-point)))
- (setf (plist-get entry :selected)
- (not (plist-get entry :selected)))
- (cj/mu4e--attachment-selection-render)))
-
-(defun cj/mu4e-attachment-selection-mark-all ()
- "Mark all attachments in the selection buffer."
- (interactive)
- (dolist (entry cj/mu4e-attachment-selection-entries)
- (setf (plist-get entry :selected) t))
- (cj/mu4e--attachment-selection-render))
-
-(defun cj/mu4e-attachment-selection-unmark-all ()
- "Unmark all attachments in the selection buffer."
- (interactive)
- (dolist (entry cj/mu4e-attachment-selection-entries)
- (setf (plist-get entry :selected) nil))
- (cj/mu4e--attachment-selection-render))
-
-(defun cj/mu4e-attachment-selection-save-marked ()
- "Save marked attachments from the selection buffer."
- (interactive)
- (let ((parts (mapcar (lambda (entry) (plist-get entry :part))
- (seq-filter (lambda (entry)
- (plist-get entry :selected))
- cj/mu4e-attachment-selection-entries))))
- (unless parts
- (user-error "No attachments selected"))
- (let ((paths (cj/mu4e--save-attachment-parts
- parts cj/mu4e-attachment-selection-directory)))
- (message "Saved %d attachment%s to %s"
- (length paths)
- (if (= (length paths) 1) "" "s")
- cj/mu4e-attachment-selection-directory)
- paths)))
-
-(defun cj/mu4e--open-attachment-selection-buffer (parts directory)
- "Open an attachment selection buffer for PARTS and DIRECTORY."
- (let ((buffer (get-buffer-create "*mu4e attachments*")))
- (with-current-buffer buffer
- (cj/mu4e-attachment-selection-mode)
- (cj/mu4e--attachment-selection-setup parts directory))
- (pop-to-buffer buffer)))
-
-(defun cj/mu4e-save-some-attachments ()
- "Prompt for a directory and open a buffer to select attachments to save."
- (interactive)
- (let ((parts (cj/mu4e--attachment-parts)))
- (unless parts
- (user-error "No attachments for this message"))
- (let ((directory (cj/mu4e--read-attachment-directory parts)))
- (cj/mu4e--open-attachment-selection-buffer parts directory))))
-
;; -------------------- HarfBuzz Crash Fix: Disable Composition ---------------
;; Disable auto-composition in mu4e headers to prevent SIGSEGV from HarfBuzz
;; when shaping emoji characters in email subjects. See Commentary above.
diff --git a/modules/mu4e-attachments.el b/modules/mu4e-attachments.el
new file mode 100644
index 00000000..87392681
--- /dev/null
+++ b/modules/mu4e-attachments.el
@@ -0,0 +1,245 @@
+;;; mu4e-attachments.el --- Save attachments from mu4e view messages -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Project-owned commands for saving attachments out of a mu4e message view.
+;;
+;; - `cj/mu4e-save-all-attachments' saves every attachment.
+;; - `cj/mu4e-save-attachment-here' prompts for one attachment and saves it.
+;; - `cj/mu4e-save-some-attachments' opens a selection buffer: RET toggles a
+;; row, a marks all, u unmarks all, s saves the marked rows, q quits.
+;; All three prompt for a destination directory.
+;;
+;; The keybindings live in `mail-config' under `cj/email-map' (C-; e).
+
+;;; Code:
+
+(require 'seq)
+
+(defvar mu4e-uniquify-save-file-name-function)
+(defvar-local cj/mu4e-attachment-selection-directory nil
+ "Destination directory for the current attachment selection buffer.")
+(defvar-local cj/mu4e-attachment-selection-entries nil
+ "Attachment selection entries for the current selection buffer.")
+
+(declare-function mm-save-part-to-file "mm-decode" (handle filename))
+(declare-function mu4e-join-paths "mu4e-helpers" (directory &rest components))
+(declare-function mu4e-view-mime-parts "mu4e-mime-parts" ())
+
+;; --------------------------- Attachment Saving ------------------------------
+
+(defun cj/mu4e--attachment-parts (&optional parts)
+ "Return attachment-like MIME PARTS for the current mu4e view message.
+When PARTS is nil, read parts from `mu4e-view-mime-parts'."
+ (seq-filter (lambda (part) (plist-get part :attachment-like))
+ (or parts
+ (progn
+ (unless (fboundp 'mu4e-view-mime-parts)
+ (require 'mu4e-mime-parts))
+ (mu4e-view-mime-parts)))))
+
+(defun cj/mu4e--attachment-duplicate-filenames (parts)
+ "Return filenames that appear more than once in PARTS."
+ (let ((counts (make-hash-table :test 'equal))
+ duplicates)
+ (dolist (part parts)
+ (let ((filename (plist-get part :filename)))
+ (puthash filename (1+ (gethash filename counts 0)) counts)))
+ (maphash (lambda (filename count)
+ (when (> count 1)
+ (push filename duplicates)))
+ counts)
+ duplicates))
+
+(defun cj/mu4e--attachment-label (part duplicate-filenames)
+ "Return a completion label for PART.
+DUPLICATE-FILENAMES is a list of filenames that need part-index disambiguation."
+ (let ((filename (or (plist-get part :filename) "unnamed-attachment")))
+ (if (member filename duplicate-filenames)
+ (format "%s <part %s>" filename (plist-get part :part-index))
+ filename)))
+
+(defun cj/mu4e--attachment-candidates (parts)
+ "Return completion candidates for attachment PARTS.
+The result is an alist of display labels to MIME part plists."
+ (let ((duplicates (cj/mu4e--attachment-duplicate-filenames parts)))
+ (mapcar (lambda (part)
+ (cons (cj/mu4e--attachment-label part duplicates) part))
+ parts)))
+
+(defun cj/mu4e--attachment-default-directory (parts)
+ "Return a sensible default save directory for attachment PARTS."
+ (file-name-as-directory
+ (or (plist-get (car parts) :target-dir)
+ (expand-file-name "~/Downloads/"))))
+
+(defun cj/mu4e--read-attachment-directory (parts)
+ "Prompt for a destination directory for attachment PARTS."
+ (file-name-as-directory
+ (read-directory-name "Save attachments to: "
+ (cj/mu4e--attachment-default-directory parts))))
+
+(defun cj/mu4e--ensure-attachment-save-functions ()
+ "Load mu4e MIME support when attachment save helpers need it."
+ (unless (and (boundp 'mu4e-uniquify-save-file-name-function)
+ (fboundp 'mu4e-join-paths))
+ (require 'mu4e-mime-parts)))
+
+(defun cj/mu4e--save-attachment-part (part directory)
+ "Save attachment PART to DIRECTORY and return the final path."
+ (cj/mu4e--ensure-attachment-save-functions)
+ (let ((handle (plist-get part :handle)))
+ (unless handle
+ (user-error "Attachment has no MIME handle: %s"
+ (or (plist-get part :filename) "<unnamed>")))
+ (let* ((path (funcall mu4e-uniquify-save-file-name-function
+ (mu4e-join-paths directory
+ (plist-get part :filename)))))
+ (mm-save-part-to-file handle path)
+ path)))
+
+(defun cj/mu4e--save-attachment-parts (parts directory)
+ "Save attachment PARTS to DIRECTORY and return the saved paths."
+ (mapcar (lambda (part)
+ (cj/mu4e--save-attachment-part part directory))
+ parts))
+
+(defun cj/mu4e-save-all-attachments ()
+ "Prompt for a directory and save all attachments in the current mu4e message."
+ (interactive)
+ (let ((parts (cj/mu4e--attachment-parts)))
+ (unless parts
+ (user-error "No attachments for this message"))
+ (let* ((directory (cj/mu4e--read-attachment-directory parts))
+ (paths (cj/mu4e--save-attachment-parts parts directory)))
+ (message "Saved %d attachment%s to %s"
+ (length paths)
+ (if (= (length paths) 1) "" "s")
+ directory)
+ paths)))
+
+(defun cj/mu4e-save-attachment-here ()
+ "Prompt for one attachment and a directory, then save that attachment."
+ (interactive)
+ (let ((parts (cj/mu4e--attachment-parts)))
+ (unless parts
+ (user-error "No attachments for this message"))
+ (let* ((directory (cj/mu4e--read-attachment-directory parts))
+ (candidates (cj/mu4e--attachment-candidates parts))
+ (choice (completing-read "Save attachment: " candidates nil t))
+ (part (cdr (assoc choice candidates)))
+ (path (cj/mu4e--save-attachment-part part directory)))
+ (message "Saved attachment to %s" path)
+ path)))
+
+(defvar cj/mu4e-attachment-selection-mode-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map (kbd "RET") #'cj/mu4e-attachment-selection-toggle)
+ (define-key map (kbd "a") #'cj/mu4e-attachment-selection-mark-all)
+ (define-key map (kbd "u") #'cj/mu4e-attachment-selection-unmark-all)
+ (define-key map (kbd "s") #'cj/mu4e-attachment-selection-save-marked)
+ (define-key map (kbd "q") #'quit-window)
+ map)
+ "Keymap for `cj/mu4e-attachment-selection-mode'.")
+
+(define-derived-mode cj/mu4e-attachment-selection-mode special-mode "Mail Attachments"
+ "Mode for selecting mu4e attachments to save.")
+
+(defun cj/mu4e--attachment-selection-entry-at-point ()
+ "Return the attachment selection entry at point."
+ (or (get-text-property (point) 'cj/mu4e-attachment-entry)
+ (get-text-property (line-beginning-position) 'cj/mu4e-attachment-entry)
+ (user-error "No attachment on this line")))
+
+(defun cj/mu4e--attachment-selection-render ()
+ "Render the current attachment selection buffer."
+ (let ((inhibit-read-only t)
+ (point-line (line-number-at-pos)))
+ (erase-buffer)
+ (insert (format "Save attachments to: %s\n\n"
+ cj/mu4e-attachment-selection-directory))
+ (insert "RET toggle a mark all u unmark all s save marked q quit\n\n")
+ (dolist (entry cj/mu4e-attachment-selection-entries)
+ (let* ((part (plist-get entry :part))
+ (mark (if (plist-get entry :selected) "[x]" "[ ]"))
+ (label (plist-get entry :label))
+ (mime-type (or (plist-get part :mime-type) ""))
+ (size (if-let ((bytes (plist-get part :decoded-size-approx)))
+ (file-size-human-readable bytes)
+ "")))
+ (insert
+ (propertize
+ (format "%s %-40s %-24s %s\n" mark label mime-type size)
+ 'cj/mu4e-attachment-entry entry))))
+ (goto-char (point-min))
+ (forward-line (max 0 (1- point-line)))))
+
+(defun cj/mu4e--attachment-selection-setup (parts directory)
+ "Populate the current selection buffer with attachment PARTS and DIRECTORY."
+ (setq cj/mu4e-attachment-selection-directory directory)
+ (setq cj/mu4e-attachment-selection-entries
+ (mapcar (lambda (candidate)
+ (list :label (car candidate)
+ :part (cdr candidate)
+ :selected nil))
+ (cj/mu4e--attachment-candidates parts)))
+ (cj/mu4e--attachment-selection-render))
+
+(defun cj/mu4e-attachment-selection-toggle ()
+ "Toggle the attachment entry at point."
+ (interactive)
+ (let ((entry (cj/mu4e--attachment-selection-entry-at-point)))
+ (setf (plist-get entry :selected)
+ (not (plist-get entry :selected)))
+ (cj/mu4e--attachment-selection-render)))
+
+(defun cj/mu4e-attachment-selection-mark-all ()
+ "Mark all attachments in the selection buffer."
+ (interactive)
+ (dolist (entry cj/mu4e-attachment-selection-entries)
+ (setf (plist-get entry :selected) t))
+ (cj/mu4e--attachment-selection-render))
+
+(defun cj/mu4e-attachment-selection-unmark-all ()
+ "Unmark all attachments in the selection buffer."
+ (interactive)
+ (dolist (entry cj/mu4e-attachment-selection-entries)
+ (setf (plist-get entry :selected) nil))
+ (cj/mu4e--attachment-selection-render))
+
+(defun cj/mu4e-attachment-selection-save-marked ()
+ "Save marked attachments from the selection buffer."
+ (interactive)
+ (let ((parts (mapcar (lambda (entry) (plist-get entry :part))
+ (seq-filter (lambda (entry)
+ (plist-get entry :selected))
+ cj/mu4e-attachment-selection-entries))))
+ (unless parts
+ (user-error "No attachments selected"))
+ (let ((paths (cj/mu4e--save-attachment-parts
+ parts cj/mu4e-attachment-selection-directory)))
+ (message "Saved %d attachment%s to %s"
+ (length paths)
+ (if (= (length paths) 1) "" "s")
+ cj/mu4e-attachment-selection-directory)
+ paths)))
+
+(defun cj/mu4e--open-attachment-selection-buffer (parts directory)
+ "Open an attachment selection buffer for PARTS and DIRECTORY."
+ (let ((buffer (get-buffer-create "*mu4e attachments*")))
+ (with-current-buffer buffer
+ (cj/mu4e-attachment-selection-mode)
+ (cj/mu4e--attachment-selection-setup parts directory))
+ (pop-to-buffer buffer)))
+
+(defun cj/mu4e-save-some-attachments ()
+ "Prompt for a directory and open a buffer to select attachments to save."
+ (interactive)
+ (let ((parts (cj/mu4e--attachment-parts)))
+ (unless parts
+ (user-error "No attachments for this message"))
+ (let ((directory (cj/mu4e--read-attachment-directory parts)))
+ (cj/mu4e--open-attachment-selection-buffer parts directory))))
+
+(provide 'mu4e-attachments)
+;;; mu4e-attachments.el ends here
diff --git a/tests/test-mail-config.el b/tests/test-mail-config.el
new file mode 100644
index 00000000..d2e2cc0b
--- /dev/null
+++ b/tests/test-mail-config.el
@@ -0,0 +1,24 @@
+;;; test-mail-config.el --- Tests for mail-config keymap wiring -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests that mail-config wires the attachment-save commands (defined in
+;; `mu4e-attachments') into its `cj/email-map' prefix keymap.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'mail-config)
+
+(ert-deftest test-mail-config-email-map-attachment-bindings ()
+ "Normal: the email prefix map routes to the attachment-save commands."
+ (should (eq (lookup-key cj/email-map (kbd "S"))
+ #'cj/mu4e-save-all-attachments))
+ (should (eq (lookup-key cj/email-map (kbd "s"))
+ #'cj/mu4e-save-attachment-here))
+ (should (eq (lookup-key cj/email-map (kbd "m"))
+ #'cj/mu4e-save-some-attachments)))
+
+(provide 'test-mail-config)
+;;; test-mail-config.el ends here
diff --git a/tests/test-mail-config-attachments.el b/tests/test-mu4e-attachments.el
index fae50c9c..86a42916 100644
--- a/tests/test-mail-config-attachments.el
+++ b/tests/test-mu4e-attachments.el
@@ -1,8 +1,8 @@
-;;; test-mail-config-attachments.el --- Tests for mu4e attachment helpers -*- lexical-binding: t; -*-
+;;; test-mu4e-attachments.el --- Tests for mu4e attachment helpers -*- lexical-binding: t; -*-
;;; Commentary:
-;; Tests project-owned attachment save helpers without requiring a live mu4e
-;; view buffer or real MIME handles.
+;; Tests the project-owned attachment save helpers in `mu4e-attachments'
+;; without requiring a live mu4e view buffer or real MIME handles.
;;; Code:
@@ -10,11 +10,11 @@
(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'mail-config)
+(require 'mu4e-attachments)
(defvar mu4e-uniquify-save-file-name-function)
-(defun test-mail-config-attachment--part (filename index &optional handle)
+(defun test-mu4e-attachments--part (filename index &optional handle)
"Return a fake attachment part for FILENAME at INDEX."
(list :filename filename
:part-index index
@@ -23,9 +23,9 @@
:attachment-like t
:handle (or handle (format "handle-%s" index))))
-(ert-deftest test-mail-config-attachments-filters-attachment-like-parts ()
- "Only attachment-like MIME parts should be saved by the custom commands."
- (let ((parts (list (test-mail-config-attachment--part "invoice.pdf" 1)
+(ert-deftest test-mu4e-attachments-filters-attachment-like-parts ()
+ "Normal: only attachment-like MIME parts are returned for saving."
+ (let ((parts (list (test-mu4e-attachments--part "invoice.pdf" 1)
(list :filename "inline.png"
:part-index 2
:attachment-like nil
@@ -34,27 +34,27 @@
(cj/mu4e--attachment-parts parts))
'("invoice.pdf")))))
-(ert-deftest test-mail-config-attachments-candidates-disambiguate-duplicates ()
- "Duplicate filenames should remain individually selectable."
- (let* ((a (test-mail-config-attachment--part "report.pdf" 1))
- (b (test-mail-config-attachment--part "report.pdf" 2))
+(ert-deftest test-mu4e-attachments-candidates-disambiguate-duplicates ()
+ "Boundary: duplicate filenames remain individually selectable."
+ (let* ((a (test-mu4e-attachments--part "report.pdf" 1))
+ (b (test-mu4e-attachments--part "report.pdf" 2))
(candidates (cj/mu4e--attachment-candidates (list a b))))
(should (equal (mapcar #'car candidates)
'("report.pdf <part 1>" "report.pdf <part 2>")))
(should (eq (cdr (assoc "report.pdf <part 1>" candidates)) a))
(should (eq (cdr (assoc "report.pdf <part 2>" candidates)) b))))
-(ert-deftest test-mail-config-attachments-candidates-keep-unique-names-simple ()
- "Unique filenames should be shown without extra part-index labels."
- (let* ((a (test-mail-config-attachment--part "invoice.pdf" 1))
- (b (test-mail-config-attachment--part "receipt.pdf" 2))
+(ert-deftest test-mu4e-attachments-candidates-keep-unique-names-simple ()
+ "Normal: unique filenames are shown without extra part-index labels."
+ (let* ((a (test-mu4e-attachments--part "invoice.pdf" 1))
+ (b (test-mu4e-attachments--part "receipt.pdf" 2))
(candidates (cj/mu4e--attachment-candidates (list a b))))
(should (equal (mapcar #'car candidates)
'("invoice.pdf" "receipt.pdf")))))
-(ert-deftest test-mail-config-attachments-save-part-uses-mu4e-save-path ()
- "Saving a part should use mu4e path joining, uniquifying, and MIME saving."
- (let ((part (test-mail-config-attachment--part "invoice.pdf" 3 "handle"))
+(ert-deftest test-mu4e-attachments-save-part-uses-mu4e-save-path ()
+ "Normal: saving a part uses mu4e path joining, uniquifying, and MIME saving."
+ (let ((part (test-mu4e-attachments--part "invoice.pdf" 3 "handle"))
(mu4e-uniquify-save-file-name-function
(lambda (path) (concat path ".unique")))
saved)
@@ -66,17 +66,19 @@
"/downloads/invoice.pdf.unique"))
(should (equal saved '("handle" "/downloads/invoice.pdf.unique"))))))
-(ert-deftest test-mail-config-attachments-save-part-errors-without-handle ()
- "A malformed part without a MIME handle should fail clearly."
- (let ((part (test-mail-config-attachment--part "invoice.pdf" 3 nil)))
+(ert-deftest test-mu4e-attachments-save-part-errors-without-handle ()
+ "Error: a malformed part without a MIME handle fails clearly."
+ (let ((part (test-mu4e-attachments--part "invoice.pdf" 3 nil))
+ (mu4e-uniquify-save-file-name-function #'identity))
(setq part (plist-put part :handle nil))
- (should-error (cj/mu4e--save-attachment-part part "/downloads")
- :type 'user-error)))
-
-(ert-deftest test-mail-config-attachments-save-all-prompts-once ()
- "The save-all command should prompt for a directory once and save all parts."
- (let ((parts (list (test-mail-config-attachment--part "a.pdf" 1)
- (test-mail-config-attachment--part "b.pdf" 2)))
+ (cl-letf (((symbol-function 'mu4e-join-paths) #'list))
+ (should-error (cj/mu4e--save-attachment-part part "/downloads")
+ :type 'user-error))))
+
+(ert-deftest test-mu4e-attachments-save-all-prompts-once ()
+ "Normal: the save-all command prompts for a directory once and saves all parts."
+ (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1)
+ (test-mu4e-attachments--part "b.pdf" 2)))
saved
prompts)
(cl-letf (((symbol-function 'mu4e-view-mime-parts)
@@ -95,10 +97,10 @@
(should (= 1 (length prompts)))
(should (equal saved (list parts "/downloads/"))))))
-(ert-deftest test-mail-config-attachments-save-selected-prompts-and-selects-one ()
- "The selected-attachment command should complete by label and save one part."
- (let* ((a (test-mail-config-attachment--part "a.pdf" 1))
- (b (test-mail-config-attachment--part "b.pdf" 2))
+(ert-deftest test-mu4e-attachments-save-selected-prompts-and-selects-one ()
+ "Normal: the selected-attachment command completes by label and saves one part."
+ (let* ((a (test-mu4e-attachments--part "a.pdf" 1))
+ (b (test-mu4e-attachments--part "b.pdf" 2))
(parts (list a b))
saved)
(cl-letf (((symbol-function 'mu4e-view-mime-parts)
@@ -118,18 +120,11 @@
(should (equal (cj/mu4e-save-attachment-here) "/downloads/b.pdf"))
(should (equal saved (list b "/downloads/"))))))
-(ert-deftest test-mail-config-attachments-email-map-bindings ()
- "Attachment save commands should be available from the email prefix map."
- (should (eq (lookup-key cj/email-map (kbd "S"))
- #'cj/mu4e-save-all-attachments))
- (should (eq (lookup-key cj/email-map (kbd "s"))
- #'cj/mu4e-save-attachment-here)))
-
-(ert-deftest test-mail-config-attachments-selection-buffer-renders-parts ()
- "Opening the selection buffer should render attachment rows with metadata."
- (let* ((a (test-mail-config-attachment--part "a.pdf" 1))
- (b (test-mail-config-attachment--part "b.pdf" 2))
- (buffer (get-buffer-create "*test-mail-attachments*")))
+(ert-deftest test-mu4e-attachments-selection-buffer-renders-parts ()
+ "Normal: opening the selection buffer renders attachment rows with metadata."
+ (let* ((a (test-mu4e-attachments--part "a.pdf" 1))
+ (b (test-mu4e-attachments--part "b.pdf" 2))
+ (buffer (get-buffer-create "*test-mu4e-attachments*")))
(unwind-protect
(with-current-buffer buffer
(cj/mu4e-attachment-selection-mode)
@@ -144,10 +139,10 @@
(should (string-match-p "application/pdf" (buffer-string))))
(kill-buffer buffer))))
-(ert-deftest test-mail-config-attachments-selection-toggle-current-row ()
- "RET should toggle the selected state for the attachment at point."
- (let* ((a (test-mail-config-attachment--part "a.pdf" 1))
- (buffer (get-buffer-create "*test-mail-attachments*")))
+(ert-deftest test-mu4e-attachments-selection-toggle-current-row ()
+ "Normal: RET toggles the selected state for the attachment at point."
+ (let* ((a (test-mu4e-attachments--part "a.pdf" 1))
+ (buffer (get-buffer-create "*test-mu4e-attachments*")))
(unwind-protect
(with-current-buffer buffer
(cj/mu4e-attachment-selection-mode)
@@ -162,11 +157,11 @@
(should (string-match-p "\\[ \\] a\\.pdf" (buffer-string))))
(kill-buffer buffer))))
-(ert-deftest test-mail-config-attachments-selection-mark-all-and-unmark-all ()
- "Selection buffer should support marking and unmarking all rows."
- (let ((parts (list (test-mail-config-attachment--part "a.pdf" 1)
- (test-mail-config-attachment--part "b.pdf" 2)))
- (buffer (get-buffer-create "*test-mail-attachments*")))
+(ert-deftest test-mu4e-attachments-selection-mark-all-and-unmark-all ()
+ "Normal: the selection buffer supports marking and unmarking all rows."
+ (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1)
+ (test-mu4e-attachments--part "b.pdf" 2)))
+ (buffer (get-buffer-create "*test-mu4e-attachments*")))
(unwind-protect
(with-current-buffer buffer
(cj/mu4e-attachment-selection-mode)
@@ -179,11 +174,11 @@
cj/mu4e-attachment-selection-entries)))
(kill-buffer buffer))))
-(ert-deftest test-mail-config-attachments-selection-save-marked ()
- "Saving marked rows should save only selected attachment parts."
- (let* ((a (test-mail-config-attachment--part "a.pdf" 1))
- (b (test-mail-config-attachment--part "b.pdf" 2))
- (buffer (get-buffer-create "*test-mail-attachments*"))
+(ert-deftest test-mu4e-attachments-selection-save-marked ()
+ "Normal: saving marked rows saves only the selected attachment parts."
+ (let* ((a (test-mu4e-attachments--part "a.pdf" 1))
+ (b (test-mu4e-attachments--part "b.pdf" 2))
+ (buffer (get-buffer-create "*test-mu4e-attachments*"))
saved)
(unwind-protect
(with-current-buffer buffer
@@ -200,22 +195,22 @@
(should (equal saved (list (list b) "/downloads/")))))
(kill-buffer buffer))))
-(ert-deftest test-mail-config-attachments-selection-save-marked-errors-when-empty ()
- "Saving with no marked rows should fail clearly."
- (let ((buffer (get-buffer-create "*test-mail-attachments*")))
+(ert-deftest test-mu4e-attachments-selection-save-marked-errors-when-empty ()
+ "Error: saving with no marked rows fails clearly."
+ (let ((buffer (get-buffer-create "*test-mu4e-attachments*")))
(unwind-protect
(with-current-buffer buffer
(cj/mu4e-attachment-selection-mode)
(cj/mu4e--attachment-selection-setup
- (list (test-mail-config-attachment--part "a.pdf" 1))
+ (list (test-mu4e-attachments--part "a.pdf" 1))
"/downloads/")
(should-error (cj/mu4e-attachment-selection-save-marked)
:type 'user-error))
(kill-buffer buffer))))
-(ert-deftest test-mail-config-attachments-save-some-opens-selection-buffer ()
- "The save-some command should prompt for a directory and open a selector."
- (let ((parts (list (test-mail-config-attachment--part "a.pdf" 1)))
+(ert-deftest test-mu4e-attachments-save-some-opens-selection-buffer ()
+ "Normal: the save-some command prompts for a directory and opens a selector."
+ (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1)))
opened)
(cl-letf (((symbol-function 'mu4e-view-mime-parts)
(lambda () parts))
@@ -228,27 +223,22 @@
(should (eq (cj/mu4e-save-some-attachments) :buffer))
(should (equal opened (list parts "/downloads/"))))))
-(ert-deftest test-mail-config-attachments-email-map-save-some-binding ()
- "The email prefix map should include a save-some binding."
- (should (eq (lookup-key cj/email-map (kbd "m"))
- #'cj/mu4e-save-some-attachments)))
-
-(ert-deftest test-mail-config-attachments-default-directory-uses-target-dir ()
+(ert-deftest test-mu4e-attachments-default-directory-uses-target-dir ()
"Normal: the default save directory comes from the part's :target-dir."
- (let ((parts (list (test-mail-config-attachment--part "a.pdf" 1))))
+ (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1))))
(should (equal (cj/mu4e--attachment-default-directory parts)
"/tmp/mail-target/"))))
-(ert-deftest test-mail-config-attachments-default-directory-falls-back-to-downloads ()
+(ert-deftest test-mu4e-attachments-default-directory-falls-back-to-downloads ()
"Boundary: with no :target-dir hint, the default is ~/Downloads/."
(let ((parts (list (list :filename "a.pdf" :part-index 1 :attachment-like t))))
(should (equal (cj/mu4e--attachment-default-directory parts)
(file-name-as-directory (expand-file-name "~/Downloads/"))))))
-(ert-deftest test-mail-config-attachments-selection-entry-at-point-errors-off-row ()
+(ert-deftest test-mu4e-attachments-selection-entry-at-point-errors-off-row ()
"Error: asking for the attachment at point on a header line fails clearly."
- (let* ((a (test-mail-config-attachment--part "a.pdf" 1))
- (buffer (get-buffer-create "*test-mail-attachments*")))
+ (let* ((a (test-mu4e-attachments--part "a.pdf" 1))
+ (buffer (get-buffer-create "*test-mu4e-attachments*")))
(unwind-protect
(with-current-buffer buffer
(cj/mu4e-attachment-selection-mode)
@@ -258,5 +248,5 @@
:type 'user-error))
(kill-buffer buffer))))
-(provide 'test-mail-config-attachments)
-;;; test-mail-config-attachments.el ends here
+(provide 'test-mu4e-attachments)
+;;; test-mu4e-attachments.el ends here