From 22232fc39598ffc065ee134889c3143566be5faa Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 12 May 2026 00:34:03 -0500 Subject: 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. --- modules/mail-config.el | 232 +----------------------------- modules/mu4e-attachments.el | 245 +++++++++++++++++++++++++++++++ tests/test-mail-config-attachments.el | 262 ---------------------------------- tests/test-mail-config.el | 24 ++++ tests/test-mu4e-attachments.el | 252 ++++++++++++++++++++++++++++++++ 5 files changed, 524 insertions(+), 491 deletions(-) create mode 100644 modules/mu4e-attachments.el delete mode 100644 tests/test-mail-config-attachments.el create mode 100644 tests/test-mail-config.el create mode 100644 tests/test-mu4e-attachments.el 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 " 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) ""))) - (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 +;; +;;; 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 " 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) ""))) + (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-attachments.el b/tests/test-mail-config-attachments.el deleted file mode 100644 index fae50c9c..00000000 --- a/tests/test-mail-config-attachments.el +++ /dev/null @@ -1,262 +0,0 @@ -;;; 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 " "report.pdf "))) - (should (eq (cdr (assoc "report.pdf " candidates)) a)) - (should (eq (cdr (assoc "report.pdf " 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))) - -(ert-deftest test-mail-config-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)))) - (should (equal (cj/mu4e--attachment-default-directory parts) - "/tmp/mail-target/")))) - -(ert-deftest test-mail-config-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 () - "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*"))) - (unwind-protect - (with-current-buffer buffer - (cj/mu4e-attachment-selection-mode) - (cj/mu4e--attachment-selection-setup (list a) "/downloads/") - (goto-char (point-min)) ; first line is the "Save attachments to:" header - (should-error (cj/mu4e--attachment-selection-entry-at-point) - :type 'user-error)) - (kill-buffer buffer)))) - -(provide 'test-mail-config-attachments) -;;; test-mail-config-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-mu4e-attachments.el b/tests/test-mu4e-attachments.el new file mode 100644 index 00000000..86a42916 --- /dev/null +++ b/tests/test-mu4e-attachments.el @@ -0,0 +1,252 @@ +;;; test-mu4e-attachments.el --- Tests for mu4e attachment helpers -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests the project-owned attachment save helpers in `mu4e-attachments' +;; 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 'mu4e-attachments) + +(defvar mu4e-uniquify-save-file-name-function) + +(defun test-mu4e-attachments--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-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 + :handle "inline")))) + (should (equal (mapcar (lambda (part) (plist-get part :filename)) + (cj/mu4e--attachment-parts parts)) + '("invoice.pdf"))))) + +(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 " "report.pdf "))) + (should (eq (cdr (assoc "report.pdf " candidates)) a)) + (should (eq (cdr (assoc "report.pdf " candidates)) b)))) + +(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-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) + (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-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)) + (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) + (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-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) + (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-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) + (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-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) + (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-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) + (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-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 + (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-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-mu4e-attachments--part "a.pdf" 1)) + "/downloads/") + (should-error (cj/mu4e-attachment-selection-save-marked) + :type 'user-error)) + (kill-buffer buffer)))) + +(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)) + ((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-mu4e-attachments-default-directory-uses-target-dir () + "Normal: the default save directory comes from the part's :target-dir." + (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1)))) + (should (equal (cj/mu4e--attachment-default-directory parts) + "/tmp/mail-target/")))) + +(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-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-mu4e-attachments--part "a.pdf" 1)) + (buffer (get-buffer-create "*test-mu4e-attachments*"))) + (unwind-protect + (with-current-buffer buffer + (cj/mu4e-attachment-selection-mode) + (cj/mu4e--attachment-selection-setup (list a) "/downloads/") + (goto-char (point-min)) ; first line is the "Save attachments to:" header + (should-error (cj/mu4e--attachment-selection-entry-at-point) + :type 'user-error)) + (kill-buffer buffer)))) + +(provide 'test-mu4e-attachments) +;;; test-mu4e-attachments.el ends here -- cgit v1.2.3