summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-11 17:17:54 -0500
committerCraig Jennings <c@cjennings.net>2026-05-11 17:17:54 -0500
commit1aa8d0f60ceec62d37c34d5cb31c46434a647ff6 (patch)
tree89938265ca4048523993270cc9faa6b2a236e242
parentb3b537fb74de7950ccd58d0f09cd7f5fbf39f546 (diff)
downloaddotemacs-1aa8d0f60ceec62d37c34d5cb31c46434a647ff6.tar.gz
dotemacs-1aa8d0f60ceec62d37c34d5cb31c46434a647ff6.zip
feat(mu4e): simpler attachment-save commands on C-; e S/s/m
Three project-owned commands that reuse mu4e's MIME metadata (`mu4e-view-mime-parts') and save primitives (`mm-save-part-to-file', `mu4e-uniquify-save-file-name-function') directly instead of driving mu4e's completion UI. `cj/mu4e-save-all-attachments' (`C-; e S') prompts once for a directory and saves every attachment-like part. `cj/mu4e-save-attachment-here' (`C-; e s') saves one attachment, picked by display label, with duplicate filenames shown as "name <part N>" so they don't collapse into one completion candidate. `cj/mu4e-save-some-attachments' (`C-; e m') opens a `*mu4e attachments*' selection buffer showing mark state, label, MIME type, and size per row, where `RET' toggles a row, `a' / `u' mark / unmark all, `s' saves the marked ones, and `q' quits. Replaced the old Embark/Vertico-workaround comment. Tests cover the attachment filtering, the duplicate-filename disambiguation, save-path construction, the no-handle error, command prompting, and the email-map bindings.
-rw-r--r--modules/mail-config.el247
-rw-r--r--tests/test-mail-config-attachments.el237
2 files changed, 474 insertions, 10 deletions
diff --git a/modules/mail-config.el b/modules/mail-config.el
index 7a41e942..9d428209 100644
--- a/modules/mail-config.el
+++ b/modules/mail-config.el
@@ -7,14 +7,10 @@
;;
;; https://macowners.club/posts/email-emacs-mu4e-macos/
;;
-;; on saving attachments:
-;; After running mu4e-view-save-attachments,
-;; - invoke embark-act-all in the completion menu
-;; - followed by RET (mu4e-view-save-attachments) to save all attachments
-;;
-;; - or TAB (vertico-insert)
-;; - followed by , (comma) next to each file you want to save,
-;; - then RET (vertico-exit), to save selected attachments.
+;; 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.
;;
;; Crash Fix:
;; auto-composition-mode is disabled in mu4e-headers-mode to prevent a
@@ -26,6 +22,7 @@
(require 'user-constants)
(require 'system-lib)
+(require 'seq)
;; 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
@@ -40,6 +37,15 @@
(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.
@@ -81,6 +87,221 @@ 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.
@@ -386,15 +607,20 @@ Prompts user for the action when executing."
:doc "Email operations and account navigation"
"A" #'org-msg-attach-attach
"D" #'org-msg-attach-delete
+ "m" #'cj/mu4e-save-some-attachments
+ "S" #'cj/mu4e-save-all-attachments
"c" cj/mail-cmail-map
"d" cj/mail-dmail-map
- "g" cj/mail-gmail-map)
+ "g" cj/mail-gmail-map
+ "s" #'cj/mu4e-save-attachment-here)
(keymap-set cj/custom-keymap "e" cj/email-map)
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
"C-; e" "email menu"
"C-; e A" "attach file"
"C-; e D" "delete attachment"
+ "C-; e m" "select attachments"
+ "C-; e S" "save all attachments"
"C-; e c" "cmail"
"C-; e c i" "cmail inbox"
"C-; e c u" "cmail unread"
@@ -409,7 +635,8 @@ Prompts user for the action when executing."
"C-; e g i" "gmail inbox"
"C-; e g u" "gmail unread"
"C-; e g s" "gmail starred"
- "C-; e g l" "gmail large"))
+ "C-; e g l" "gmail large"
+ "C-; e s" "save attachment"))
:bind
;; more intuitive keybinding for attachments
(:map org-msg-edit-mode-map
diff --git a/tests/test-mail-config-attachments.el b/tests/test-mail-config-attachments.el
new file mode 100644
index 00000000..eee0ff70
--- /dev/null
+++ b/tests/test-mail-config-attachments.el
@@ -0,0 +1,237 @@
+;;; test-mail-config-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.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'mail-config)
+
+(defvar mu4e-uniquify-save-file-name-function)
+
+(defun test-mail-config-attachment--part (filename index &optional handle)
+ "Return a fake attachment part for FILENAME at INDEX."
+ (list :filename filename
+ :part-index index
+ :mime-type "application/pdf"
+ :target-dir "/tmp/mail-target"
+ :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)
+ (list :filename "inline.png"
+ :part-index 2
+ :attachment-like nil
+ :handle "inline"))))
+ (should (equal (mapcar (lambda (part) (plist-get part :filename))
+ (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))
+ (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))
+ (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"))
+ (mu4e-uniquify-save-file-name-function
+ (lambda (path) (concat path ".unique")))
+ saved)
+ (cl-letf (((symbol-function 'mu4e-join-paths)
+ (lambda (&rest pieces) (mapconcat #'identity pieces "/")))
+ ((symbol-function 'mm-save-part-to-file)
+ (lambda (handle path) (setq saved (list handle path)))))
+ (should (equal (cj/mu4e--save-attachment-part part "/downloads")
+ "/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)))
+ (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)))
+ saved
+ prompts)
+ (cl-letf (((symbol-function 'mu4e-view-mime-parts)
+ (lambda () parts))
+ ((symbol-function 'read-directory-name)
+ (lambda (prompt &rest _)
+ (push prompt prompts)
+ "/downloads/"))
+ ((symbol-function 'cj/mu4e--save-attachment-parts)
+ (lambda (selected directory)
+ (setq saved (list selected directory))
+ '("/downloads/a.pdf" "/downloads/b.pdf")))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (should (equal (cj/mu4e-save-all-attachments)
+ '("/downloads/a.pdf" "/downloads/b.pdf")))
+ (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))
+ (parts (list a b))
+ saved)
+ (cl-letf (((symbol-function 'mu4e-view-mime-parts)
+ (lambda () parts))
+ ((symbol-function 'read-directory-name)
+ (lambda (&rest _) "/downloads/"))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt candidates &rest _)
+ (should (equal (mapcar #'car candidates)
+ '("a.pdf" "b.pdf")))
+ "b.pdf"))
+ ((symbol-function 'cj/mu4e--save-attachment-part)
+ (lambda (part directory)
+ (setq saved (list part directory))
+ "/downloads/b.pdf"))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (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*")))
+ (unwind-protect
+ (with-current-buffer buffer
+ (cj/mu4e-attachment-selection-mode)
+ (cj/mu4e--attachment-selection-setup (list a b) "/downloads/")
+ (should (eq major-mode 'cj/mu4e-attachment-selection-mode))
+ (should (equal cj/mu4e-attachment-selection-directory "/downloads/"))
+ (should (equal (mapcar (lambda (entry)
+ (plist-get entry :label))
+ cj/mu4e-attachment-selection-entries)
+ '("a.pdf" "b.pdf")))
+ (should (string-match-p "\\[ \\] a\\.pdf" (buffer-string)))
+ (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*")))
+ (unwind-protect
+ (with-current-buffer buffer
+ (cj/mu4e-attachment-selection-mode)
+ (cj/mu4e--attachment-selection-setup (list a) "/downloads/")
+ (goto-char (point-min))
+ (search-forward "a.pdf")
+ (cj/mu4e-attachment-selection-toggle)
+ (should (plist-get (car cj/mu4e-attachment-selection-entries) :selected))
+ (should (string-match-p "\\[x\\] a\\.pdf" (buffer-string)))
+ (cj/mu4e-attachment-selection-toggle)
+ (should-not (plist-get (car cj/mu4e-attachment-selection-entries) :selected))
+ (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*")))
+ (unwind-protect
+ (with-current-buffer buffer
+ (cj/mu4e-attachment-selection-mode)
+ (cj/mu4e--attachment-selection-setup parts "/downloads/")
+ (cj/mu4e-attachment-selection-mark-all)
+ (should (seq-every-p (lambda (entry) (plist-get entry :selected))
+ cj/mu4e-attachment-selection-entries))
+ (cj/mu4e-attachment-selection-unmark-all)
+ (should-not (seq-some (lambda (entry) (plist-get entry :selected))
+ 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*"))
+ saved)
+ (unwind-protect
+ (with-current-buffer buffer
+ (cj/mu4e-attachment-selection-mode)
+ (cj/mu4e--attachment-selection-setup (list a b) "/downloads/")
+ (setf (plist-get (cadr cj/mu4e-attachment-selection-entries) :selected) t)
+ (cl-letf (((symbol-function 'cj/mu4e--save-attachment-parts)
+ (lambda (parts directory)
+ (setq saved (list parts directory))
+ '("/downloads/b.pdf")))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (should (equal (cj/mu4e-attachment-selection-save-marked)
+ '("/downloads/b.pdf")))
+ (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*")))
+ (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))
+ "/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)))
+ opened)
+ (cl-letf (((symbol-function 'mu4e-view-mime-parts)
+ (lambda () parts))
+ ((symbol-function 'read-directory-name)
+ (lambda (&rest _) "/downloads/"))
+ ((symbol-function 'cj/mu4e--open-attachment-selection-buffer)
+ (lambda (selected directory)
+ (setq opened (list selected directory))
+ :buffer)))
+ (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)))
+
+(provide 'test-mail-config-attachments)
+;;; test-mail-config-attachments.el ends here