diff --git a/README.md b/README.md index 708eafe..bc2094c 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ with the [Notmuch mail indexer](https://notmuchmail.org). 3. [Requirements](#requirements) 4. [Installation](#installation) 5. [Usage](#usage) -6. [Configuration Options](#configuration-options) -7. [License](#license) +6. [Composing and Replying](#composing-and-replying) +7. [Configuration Options](#configuration-options) +8. [License](#license) ## Introduction @@ -30,7 +31,10 @@ the familiar Vim interface and motions. - 📧 **Email Browsing**: Navigate emails with Vim-like movements. - 🔍 **Search Your Email**: Leverage `notmuch` to search your email interactively. - 🔗 **Thread Viewing**: Messages are loaded with folding and threading intact. -- 📎 **Attachment Management**: View, open and save attachments easily. +- 📎 **Attachment Management**: View, open and save attachments from received mail. +- ✉️ **Compose and Reply**: Write new emails and replies with MIME support. +- 📂 **Outgoing Attachments**: Attach files via commands, prompts, or paste from + [oil.nvim](https://github.com/stevearc/oil.nvim) buffers. - 🌐 **Inline HTML Rendering**: Render HTML email bodies as text via `w3m`. - ⬇️ **Offline Mail Sync**: Supports `mbsync` for efficient sync processes, with buffer, background, and interactive terminal modes. - 🔓 **Async Search**: Large mailboxes with thousands of email? No problem. @@ -123,6 +127,72 @@ Here are the core commands within Notmuch.nvim: :Inbox work@example.com ``` +## Composing and Replying + +### Commands + +- **`:ComposeMail [to]`**: Open a compose buffer for a new email. Optionally + provide a recipient address. +- **`C`**: Compose a new email (mapped in hello, threads, and thread view buffers). +- **`R`**: Reply to the message under the cursor (mapped in thread view). +- **``**: Send the email from the compose buffer (configurable via + `keymaps.sendmail`). +- **``**: Toggle the attachment window (configurable via + `keymaps.attachment_window`). + +The compose buffer is a standard editable buffer with email headers (`From`, +`To`, `Cc`, `Subject`) at the top and the message body below. Sending uses +`msmtp` under the hood. + +### Attachments + +There are several ways to attach files to an outgoing email: + +#### 1. `:Attach` command + +From the compose buffer, run `:Attach ` with file completion: + +```vim +:Attach ~/Documents/report.pdf +:Attach /tmp/screenshot.png +``` + +Use `:AttachRemove ` to remove an attachment (with completion from the +current attachment list), and `:AttachList` to open the attachment window. + +#### 2. Attachment window + +Press `` (or run `:AttachList`) to open the attachment buffer. This +is an editable buffer (similar to oil.nvim) where each line below the header +is a file path: + +| Key | Action | +| :--- | :--------------------------- | +| `a` | Add attachment via prompt | +| `dd` | Delete attachment | +| `p` | Paste (with oil.nvim support)| +| `:w` | Save/validate attachments | +| `q` | Close window | + +You can also type or paste absolute paths directly and press `:w` to validate +and save them. + +#### 3. Paste from oil.nvim + +If you use [oil.nvim](https://github.com/stevearc/oil.nvim), you can yank a +line from an oil buffer (`yy`) and paste it into the attachment window with +`p`. The plugin uses oil's API (`oil.get_entry_on_line()` and +`oil.get_current_dir()`) to resolve the actual file path regardless of your +oil column configuration. + +This also works as a fallback when saving (`:w`) the attachment buffer -- if a +line doesn't resolve as a direct path, the plugin checks open oil buffers to +see if it matches a rendered oil line and resolves it automatically. + +> **Note**: oil.nvim is entirely optional. All attachment features work without +> it. The oil integration is a convenience for users who already use oil as +> their file manager. + ## Configuration Options You can configure several global options to tailor the plugin's behavior: diff --git a/doc/notmuch.txt b/doc/notmuch.txt index b6dc4e4..01c2a9d 100644 --- a/doc/notmuch.txt +++ b/doc/notmuch.txt @@ -12,6 +12,8 @@ CONTENTS *notmuch-contents* Other Notmuch Plugins |notmuch-other-plugins| Usage |notmuch-usage| Commands |notmuch-commands| + Composing and Replying |notmuch-compose| + Attachments |notmuch-attachments| Options |notmuch-options| Completion |notmuch-completion| Behavior |notmuch-behavior| @@ -235,6 +237,117 @@ Notmuch.nvim plugin and the local mail system to search and browse. Provides autocomplete for email addresses from the notmuch database. Essentially implemented by the |NmSearch| command. +------------------------------------------------------------------------------ +COMPOSING AND REPLYING *notmuch-compose* + +Notmuch.nvim supports composing new emails and replying to existing messages. +The compose buffer is a standard editable buffer with email headers at the +top and the message body below. Sending is handled by `msmtp`. + + *:ComposeMail* +:ComposeMail [to] Opens a compose buffer for a new email. Optionally + provide a recipient address. The buffer is populated + with headers (From, To, Cc, Subject) and a body area. + + Available from any notmuch buffer via the `C` keymap, + or as a command from the command line: > + + :ComposeMail user@example.com +< + +Replying~ + + In thread view, press `R` to reply to the message under the cursor. This + creates a compose buffer pre-populated with the reply headers and quoted + message text (via `notmuch reply`). + +Sending~ + + Once you have composed your email, press `` (configurable via + |keymaps|) to send it. The plugin will prompt for confirmation, build the + MIME message (including any attachments), and send it via `msmtp`. + +Keymaps available in the compose buffer~ + + `` Send the email + `` Toggle the attachment window + +These keymaps are configurable via the |keymaps| option: + + `keymaps.sendmail` Default: `` + `keymaps.attachment_window` Default: `` + +------------------------------------------------------------------------------ +ATTACHMENTS *notmuch-attachments* + +There are several ways to attach files to an outgoing email. Attachments are +encoded as base64 MIME parts in the outgoing message. + +Commands~ + *:Attach* +:Attach {path} Attach a file to the email being composed. Supports + file completion. The path is expanded and converted to + an absolute path before validation. > + + :Attach ~/Documents/report.pdf + :Attach /tmp/screenshot.png +< + *:AttachRemove* +:AttachRemove {path} Remove a previously attached file. Provides completion + from the current attachment list. + + *:AttachList* +:AttachList Open the attachment buffer window. Same as pressing + `` in the compose buffer. + +Attachment Buffer~ + *notmuch-attachment-buffer* + The attachment buffer is an editable buffer where each line below the + header represents a file path. It behaves similarly to oil.nvim: you can + freely edit lines, and changes are applied when you save with `:w`. + + The buffer has a protected header area (title, hints, and separator line) + and a file area below it. Only the file area is editable. + + Keymaps in the attachment buffer: + + `a` Add attachment via prompt with file completion + `dd` Delete the attachment under the cursor + `p` Paste below (with oil.nvim line resolution) + `P` Paste above (with oil.nvim line resolution) + `gg` Jump to the first file line (skips header) + `i` Insert mode (cursor moved below header if needed) + `o` Open a new line below + `O` Open a new line above + `:w` Validate all paths, remove invalid ones, and save + `q` Close the attachment window + + When saving with `:w`, each line is expanded and validated. Invalid paths + are removed from the buffer and a warning is shown. Valid paths are stored + in the `vim.b.notmuch_attachments` buffer variable on the compose buffer. + +Pasting from oil.nvim~ + *notmuch-oil-integration* + If you use oil.nvim (https://github.com/stevearc/oil.nvim), you can yank + a line from an oil buffer and paste it into the attachment buffer with `p` + or `P`. The plugin uses oil's public API to resolve the file path: + + - `oil.get_current_dir()` to get the directory of the oil buffer + - `oil.get_entry_on_line()` to get the actual filename for a line + + This works regardless of how your oil columns are configured (icons, + permissions, dates, sizes, etc.) because the plugin matches against the + rendered buffer lines and asks oil for the real entry, rather than trying + to parse the display format. + + The oil resolution also runs as a fallback when saving (`:w`) the + attachment buffer. If a line does not resolve as a direct file path, the + plugin checks open oil buffers to see if it matches a rendered line. + + Note: oil.nvim is entirely optional. All attachment features work without + it. The `:Attach` command, the `a` prompt keymap, and typing absolute + paths directly into the buffer all work independently of oil. + ------------------------------------------------------------------------------ OPTIONS *notmuch-options* @@ -647,12 +760,22 @@ plugin's project codebase. - Directly interacts with the Notmuch database via LuaJIT bindings. - **attach.lua**: - - Manages email attachments, providing functions to: + - Manages received email attachments, providing functions to: - List attachments in a thread. - Save attachments to a directory. - Open attachments using external programs. - Supports extracting URLs from messages and following GitHub patch links. + - **attach_buffer.lua**: + - Implements the oil.nvim-style attachment buffer for composing emails. + - Handles buffer creation, display, keymaps, and sync on `:w`. + - Resolves pasted oil.nvim lines to file paths using oil's public API. + - Validates and stores attachment paths in buffer-local variables. + + - **attach_cmd.lua**: + - Defines the `:Attach`, `:AttachRemove`, and `:AttachList` buffer-local + commands available in compose buffers. + - **handlers.lua**: - Defines default handlers for opening and viewing attachments. - **default_open_handler()**: OS-aware external opener using open, diff --git a/ftplugin/mail.vim b/ftplugin/mail.vim index 2852d54..5320fff 100644 --- a/ftplugin/mail.vim +++ b/ftplugin/mail.vim @@ -9,8 +9,8 @@ if match(bufname("%"), "^thread:") != -1 command -buffer FollowPatch :call v:lua.require('notmuch.attach').follow_github_patch(getline('.')) nnoremap U call v:lua.require('notmuch.attach').get_urls_from_cursor_msg() - nnoremap zj - nnoremap zk + nnoremap call v:lua.require('notmuch.thread').next_message() + nnoremap call v:lua.require('notmuch.thread').prev_message() nnoremap za nnoremap a call v:lua.require('notmuch.attach').get_attachments_from_cursor_msg() nnoremap r call v:lua.require('notmuch.refresh').refresh_thread_buffer() diff --git a/ftplugin/notmuch-attachments.vim b/ftplugin/notmuch-attachments.vim new file mode 100644 index 0000000..d92858e --- /dev/null +++ b/ftplugin/notmuch-attachments.vim @@ -0,0 +1,24 @@ +" ftplugin for notmuch-attachments buffers +" This buffer works like oil.nvim: each line after the header is a file path. +" - Use dd to delete an attachment +" - Paste file paths with p (e.g. from oil.nvim) +" - Use a/b/t for add/browse/telescope + +if exists("b:did_ftplugin") + finish +endif +let b:did_ftplugin = 1 + +" Buffer settings - NOTE: buffer IS modifiable (oil.nvim style) +setlocal buftype=acwrite +setlocal bufhidden=hide +setlocal noswapfile +setlocal nowrap +setlocal cursorline + +" Disable some common plugins that might interfere +let b:loaded_coc = 1 +let b:coc_enabled = 0 +let b:copilot_enabled = 0 + +" Keybindings are set in the Lua module (attach_buffer.lua) diff --git a/lua/notmuch/attach.lua b/lua/notmuch/attach.lua index b19a228..9dfb30b 100644 --- a/lua/notmuch/attach.lua +++ b/lua/notmuch/attach.lua @@ -79,10 +79,22 @@ a.open_attachment_part = function() config.options.open_handler({ path = vim.fn.expand(filepath) }) end +--- Returns true if the given filepath looks like a renderable image. +---@param path string +---@return boolean +local function is_image_path(path) + return path:match('%.[pP][nN][gG]$') ~= nil + or path:match('%.[jJ][pP][eE]?[gG]$') ~= nil + or path:match('%.[gG][iI][fF]$') ~= nil + or path:match('%.[wW][eE][bB][pP]$') ~= nil + or path:match('%.[aA][vV][iI][fF]$') ~= nil +end + --- Views the MIME part at cursor in a floating window. -- --- Saves the attachment to /tmp, processes it with view_handler, --- and displays the output in a centered floating window. +-- For image attachments (png/jpg/gif/webp/avif) the image is rendered +-- directly in the floating window via image.nvim when available. +-- For all other MIME types the configured view_handler is used as before. -- Press 'q' to close the window. -- ---@return nil @@ -95,8 +107,83 @@ a.view_attachment_part = function() return nil end - -- Process with user's configured view_handler - local output = config.options.view_handler({ path = vim.fn.expand(filepath) }) + local expanded_path = vim.fn.expand(filepath) + + -- ── Image path ──────────────────────────────────────────────────────── + if is_image_path(expanded_path) then + local ok_img, image_api = pcall(require, 'image') + if ok_img then + -- Create a scratch buffer for the floating window + local buf = v.nvim_create_buf(false, true) + vim.bo[buf].bufhidden = 'wipe' + vim.bo[buf].modifiable = false + + -- Floating window - calculate size (leave a 1-cell border inside) + local width = math.floor(vim.o.columns * 0.8) + local height = math.floor(vim.o.lines * 0.8) + local col = math.floor((vim.o.columns - width) / 2) + local row = math.floor((vim.o.lines - height) / 2) + + local win = v.nvim_open_win(buf, true, { + border = 'rounded', + relative = 'editor', + style = 'minimal', + height = height, + width = width, + row = row, + col = col, + }) + + -- Render the image inside the floating window. + -- Do NOT pass x/y: those are absolute terminal coordinates and cause + -- misalignment on repeated opens. Let image.nvim derive position from + -- the window handle instead. + local img = image_api.from_file(expanded_path, { + window = win, + buffer = buf, + max_width_window_percentage = 100, + max_height_window_percentage = 100, + }) + + if img then + -- Defer render so the floating window geometry is fully settled. + vim.defer_fn(function() + if v.nvim_win_is_valid(win) then + img:render() + end + end, 50) + else + vim.notify('image.nvim: could not load image: ' .. expanded_path, vim.log.levels.WARN) + end + + -- q clears the image and closes the window. + -- IMPORTANT: nil out img.window BEFORE calling clear() so that + -- image.nvim's renderer doesn't re-insert a zombie entry into + -- state.images when it fires WinNew/WinResized autocmds for the + -- next floating window. Without this, the stale image object + -- (with the now-closed window id) gets re-rendered at the wrong + -- position on subsequent opens. + vim.keymap.set('n', 'q', function() + if img then + pcall(function() + img.window = nil + img.buffer = nil + img:clear() + end) + end + if v.nvim_win_is_valid(win) then + v.nvim_win_close(win, true) + end + end, { buffer = buf, nowait = true }) + + return + end + -- image.nvim not available: fall through to text handler below + vim.notify('image.nvim not available; falling back to view_handler', vim.log.levels.WARN) + end + + -- ── Non-image (or image.nvim unavailable) path ──────────────────────── + local output = config.options.view_handler({ path = expanded_path }) local lines = vim.split(output, '\n') -- Create new buffer for floating window @@ -109,9 +196,9 @@ a.view_attachment_part = function() local row = math.floor((vim.o.lines - height) / 2) local win = vim.api.nvim_open_win(buf, true, { - border = "rounded", - relative = "editor", - style = "minimal", + border = 'rounded', + relative = 'editor', + style = 'minimal', height = height, width = width, row = row, @@ -338,10 +425,11 @@ a.get_attachments_from_cursor_msg = function() local id = thread.get_current_message_id() if id == nil then return nil end - -- If attachment buffer already exists, notify and return + -- If attachment buffer already exists, open it in a split local bufnr = vim.fn.bufnr('id:' .. id) if bufnr ~= -1 then - vim.notify('Attachment list for this msg is already open in buffer: ' .. bufnr, vim.log.levels.WARN) + v.nvim_command('belowright 8split') + v.nvim_win_set_buf(0, bufnr) return nil end diff --git a/lua/notmuch/attach_buffer.lua b/lua/notmuch/attach_buffer.lua new file mode 100644 index 0000000..ba4fbfd --- /dev/null +++ b/lua/notmuch/attach_buffer.lua @@ -0,0 +1,529 @@ +---@module 'notmuch.attach_buffer' +--- +--- Attachment buffer for compose and reply flows. +--- Works like oil.nvim: each line after the header is a file path. +--- - `dd` deletes an attachment +--- - `p`/`P` pastes file paths (e.g. copied from oil.nvim) +--- - `a` adds via prompt with file completion +--- - `q` closes the window + +local AB = {} + +local v = vim.api +local u = require('notmuch.util') + +--- The separator line between the header and the file list area +local SEPARATOR = '───────────────────' + +--- Number of header lines (title + hints + separator) +local HEADER_LINES = 3 + +--- Builds the fixed header lines +---@return table +local function build_header_lines() + return { + '═══ Attachments ═══', + 'Hints: a: Add | dd: Delete | p: Paste | :w Save | q: Close', + SEPARATOR, + } +end + +---Creates and configures an attachment buffer for a compose/reply buffer. +---The buffer is modifiable below the header so it works like oil.nvim. +---@param main_buf number The compose/reply buffer +---@return number buf_attach The attachment buffer +AB.create_attachment_buffer = function(main_buf) + -- listed=true, scratch=false so Neovim does NOT force nomodifiable + local buf_attach = v.nvim_create_buf(true, false) + + v.nvim_buf_set_name(buf_attach, 'attachments:' .. main_buf) + + vim.bo[buf_attach].buftype = 'acwrite' + vim.bo[buf_attach].bufhidden = 'hide' + vim.bo[buf_attach].swapfile = false + vim.bo[buf_attach].modifiable = true + + -- Store reference to main buffer before setting filetype + v.nvim_buf_set_var(buf_attach, 'notmuch_main_buf', main_buf) + + -- Set filetype (triggers ftplugin) – must come after modifiable = true + vim.bo[buf_attach].filetype = 'notmuch-attachments' + + -- Ensure modifiable is still true after ftplugin ran + vim.bo[buf_attach].modifiable = true + + AB.refresh_attachment_display(buf_attach, main_buf) + AB.setup_keymaps(buf_attach, main_buf) + AB.setup_sync_autocmd(buf_attach, main_buf) + + -- Store handle on main buffer so it can be retrieved/recreated + v.nvim_buf_set_var(main_buf, 'notmuch_attach_buf', buf_attach) + + return buf_attach +end + +---Refreshes the buffer content from the notmuch_attachments variable +---@param buf_attach number +---@param main_buf number +AB.refresh_attachment_display = function(buf_attach, main_buf) + if not v.nvim_buf_is_valid(buf_attach) or not v.nvim_buf_is_valid(main_buf) then + return + end + + local ok, attachments = pcall(v.nvim_buf_get_var, main_buf, 'notmuch_attachments') + if not ok then + attachments = {} + end + + local lines = build_header_lines() + + if #attachments == 0 then + table.insert(lines, '') + else + for _, path in ipairs(attachments) do + table.insert(lines, path) + end + end + + vim.bo[buf_attach].modifiable = true + v.nvim_buf_set_lines(buf_attach, 0, -1, false, lines) + vim.bo[buf_attach].modified = false +end + +---Resolves an oil.nvim yanked line to an absolute file path. +---Instead of parsing oil's display format (which varies by user config), +---we use oil's own API: find open oil buffers, match the pasted text +---against their rendered lines, and use oil.get_entry_on_line() + +---oil.get_current_dir() to resolve the real path. +---@param line string The yanked/pasted line from an oil buffer +---@return string|nil resolved absolute path, or nil +local function resolve_oil_line(line) + local has_oil, oil = pcall(require, 'oil') + if not has_oil then + return nil + end + + local trimmed = vim.trim(line) + if trimmed == '' then + return nil + end + + -- Iterate open oil buffers and compare rendered lines + for _, bufnr in ipairs(v.nvim_list_bufs()) do + if v.nvim_buf_is_loaded(bufnr) and vim.bo[bufnr].filetype == 'oil' then + local dir = oil.get_current_dir(bufnr) + if dir then + local line_count = v.nvim_buf_line_count(bufnr) + local buf_lines = v.nvim_buf_get_lines(bufnr, 0, line_count, false) + for lnum, buf_line in ipairs(buf_lines) do + if vim.trim(buf_line) == trimmed then + local entry = oil.get_entry_on_line(bufnr, lnum) + if entry and entry.type == 'file' then + -- Ensure trailing slash on dir + if not dir:match('/$') then + dir = dir .. '/' + end + local full_path = dir .. entry.name + if vim.loop.fs_stat(full_path) then + return full_path + end + end + end + end + end + end + end + + return nil +end + +---Transforms the contents of a vim register, converting oil.nvim formatted +---lines into absolute file paths. Lines that are already valid paths are +---kept as-is. +---@param reg_content table register lines +---@return table transformed lines +---@return boolean true if any line was transformed +local function transform_register_for_paste(reg_content) + local result = {} + local transformed = false + + for _, line in ipairs(reg_content) do + local trimmed = vim.trim(line) + if trimmed == '' then + table.insert(result, line) + else + -- Check if it already looks like a valid absolute path + local expanded = vim.fn.expand(trimmed) + expanded = vim.fn.fnamemodify(expanded, ':p') + if vim.loop.fs_stat(expanded) then + table.insert(result, expanded) + if expanded ~= trimmed then + transformed = true + end + else + -- Try to parse as oil.nvim line + local resolved = resolve_oil_line(trimmed) + if resolved then + table.insert(result, resolved) + transformed = true + else + -- Keep original line; validation on :w will catch it + table.insert(result, trimmed) + end + end + end + end + + return result, transformed +end + +---Reads file paths from the buffer (lines after the separator) +---@param buf_attach number +---@return table paths +AB.parse_paths_from_buffer = function(buf_attach) + if not v.nvim_buf_is_valid(buf_attach) then + return {} + end + + local all_lines = v.nvim_buf_get_lines(buf_attach, 0, -1, false) + local paths = {} + local past_separator = false + + for _, line in ipairs(all_lines) do + if past_separator then + local trimmed = vim.trim(line) + if trimmed ~= '' then + table.insert(paths, trimmed) + end + elseif line == SEPARATOR then + past_separator = true + end + end + + return paths +end + +---Syncs buffer content -> notmuch_attachments variable on the main buffer. +---Only runs on :w (BufWriteCmd), not on every TextChanged. +---@param buf_attach number +---@param main_buf number +AB.sync_buffer_to_attachments = function(buf_attach, main_buf) + if not v.nvim_buf_is_valid(buf_attach) or not v.nvim_buf_is_valid(main_buf) then + return + end + + local paths = AB.parse_paths_from_buffer(buf_attach) + local valid_paths = {} + local invalid_paths = {} + + for _, path in ipairs(paths) do + local expanded = vim.fn.expand(path) + expanded = vim.fn.fnamemodify(expanded, ':p') + local valid, err = u.validate_attachment_file(expanded) + if valid then + table.insert(valid_paths, expanded) + else + -- Try resolving as an oil.nvim formatted line before giving up + local resolved = resolve_oil_line(path) + if resolved then + local rv, re = u.validate_attachment_file(resolved) + if rv then + table.insert(valid_paths, resolved) + else + table.insert(invalid_paths, { path = path, err = re }) + end + else + table.insert(invalid_paths, { path = path, err = err }) + end + end + end + + if #invalid_paths > 0 then + local msgs = {} + for _, inv in ipairs(invalid_paths) do + table.insert(msgs, ' ' .. inv.path .. ': ' .. (inv.err or 'unknown error')) + end + vim.notify('Invalid attachments removed:\n' .. table.concat(msgs, '\n'), vim.log.levels.WARN) + end + + v.nvim_buf_set_var(main_buf, 'notmuch_attachments', valid_paths) +end + +---Sets up TextChanged autocmd for live sync and BufWriteCmd for :w support +---@param buf_attach number +---@param main_buf number +AB.setup_sync_autocmd = function(buf_attach, main_buf) + local group = v.nvim_create_augroup('NotmuchAttachSync_' .. buf_attach, { clear = true }) + + v.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + group = group, + buffer = buf_attach, + callback = function() + AB.protect_header(buf_attach) + end, + }) + + -- Intercept :w to sync attachments (like oil.nvim) + v.nvim_create_autocmd('BufWriteCmd', { + group = group, + buffer = buf_attach, + callback = function() + AB.sync_buffer_to_attachments(buf_attach, main_buf) + + -- Remove invalid paths from the buffer and refresh display + AB.refresh_attachment_display(buf_attach, main_buf) + + local ok, attachments = pcall(v.nvim_buf_get_var, main_buf, 'notmuch_attachments') + local count = ok and #attachments or 0 + vim.notify( + string.format('Attachments saved: %d file%s', count, count == 1 and '' or 's'), + vim.log.levels.INFO + ) + end, + }) +end + +---Restores the header if it was modified +---@param buf_attach number +AB.protect_header = function(buf_attach) + if not v.nvim_buf_is_valid(buf_attach) then + return + end + + local current_lines = v.nvim_buf_get_lines(buf_attach, 0, HEADER_LINES, false) + local expected = build_header_lines() + + local needs_restore = #current_lines < HEADER_LINES + if not needs_restore then + for i = 1, HEADER_LINES do + if current_lines[i] ~= expected[i] then + needs_restore = true + break + end + end + end + + if not needs_restore then + return + end + + -- Collect file lines (everything that isn't header) + local all_lines = v.nvim_buf_get_lines(buf_attach, 0, -1, false) + local file_lines = {} + local found_sep = false + + for i, line in ipairs(all_lines) do + if found_sep then + table.insert(file_lines, line) + elseif line == SEPARATOR then + found_sep = true + elseif i > HEADER_LINES then + table.insert(file_lines, line) + end + end + + if not found_sep and #all_lines > HEADER_LINES then + for i = HEADER_LINES + 1, #all_lines do + table.insert(file_lines, all_lines[i]) + end + end + + local restored = build_header_lines() + for _, fl in ipairs(file_lines) do + table.insert(restored, fl) + end + + -- Temporarily remove autocmd to avoid recursion + local group_name = 'NotmuchAttachSync_' .. buf_attach + pcall(v.nvim_del_augroup_by_name, group_name) + + v.nvim_buf_set_lines(buf_attach, 0, -1, false, restored) + vim.bo[buf_attach].modified = false + + -- Re-create autocmd + local ok_main, main_buf = pcall(v.nvim_buf_get_var, buf_attach, 'notmuch_main_buf') + if ok_main then + AB.setup_sync_autocmd(buf_attach, main_buf) + end +end + +---Sets up keymaps on the attachment buffer +---@param buf_attach number +---@param main_buf number +AB.setup_keymaps = function(buf_attach, main_buf) + local opts = function(desc) + return { buffer = buf_attach, desc = desc, nowait = true } + end + + -- Close window + vim.keymap.set('n', 'q', function() + for _, win in ipairs(vim.fn.win_findbuf(buf_attach)) do + v.nvim_win_close(win, false) + end + end, opts('Close attachment window')) + + -- Add attachment via prompt + vim.keymap.set('n', 'a', function() + AB.prompt_add_attachment(main_buf, buf_attach) + end, opts('Add attachment')) + + -- gg goes to first file line, not the header + vim.keymap.set('n', 'gg', function() + v.nvim_win_set_cursor(0, { HEADER_LINES + 1, 0 }) + end, opts('Go to first attachment')) + + -- Prevent inserting inside header area + vim.keymap.set('n', 'i', function() + local row = v.nvim_win_get_cursor(0)[1] + if row <= HEADER_LINES then + v.nvim_win_set_cursor(0, { HEADER_LINES + 1, 0 }) + end + vim.cmd('startinsert') + end, opts('Insert')) + + vim.keymap.set('n', 'o', function() + local row = math.max(v.nvim_win_get_cursor(0)[1], HEADER_LINES) + v.nvim_buf_set_lines(buf_attach, row, row, false, { '' }) + v.nvim_win_set_cursor(0, { row + 1, 0 }) + vim.cmd('startinsert') + end, opts('New line below')) + + vim.keymap.set('n', 'O', function() + local row = v.nvim_win_get_cursor(0)[1] + if row <= HEADER_LINES then + row = HEADER_LINES + 1 + end + v.nvim_buf_set_lines(buf_attach, row - 1, row - 1, false, { '' }) + v.nvim_win_set_cursor(0, { row, 0 }) + vim.cmd('startinsert') + end, opts('New line above')) + + -- dd: delete line but protect header + vim.keymap.set('n', 'dd', function() + local row = v.nvim_win_get_cursor(0)[1] + if row <= HEADER_LINES then + return + end + vim.cmd('normal! "_dd') + end, opts('Delete attachment')) + + -- p/P: paste with oil.nvim line conversion, keeping cursor out of header + vim.keymap.set('n', 'p', function() + if v.nvim_win_get_cursor(0)[1] < HEADER_LINES then + v.nvim_win_set_cursor(0, { HEADER_LINES, 0 }) + end + -- Transform register contents (oil.nvim lines -> absolute paths) + local reg = vim.v.register ~= '' and vim.v.register or '"' + local content = vim.fn.getreg(reg, 1, true) + local transformed, changed = transform_register_for_paste(content) + if changed then + vim.fn.setreg(reg, transformed, 'l') + end + vim.cmd('normal! p') + end, opts('Paste')) + + vim.keymap.set('n', 'P', function() + if v.nvim_win_get_cursor(0)[1] <= HEADER_LINES then + v.nvim_win_set_cursor(0, { HEADER_LINES + 1, 0 }) + end + local reg = vim.v.register ~= '' and vim.v.register or '"' + local content = vim.fn.getreg(reg, 1, true) + local transformed, changed = transform_register_for_paste(content) + if changed then + vim.fn.setreg(reg, transformed, 'l') + end + vim.cmd('normal! P') + end, opts('Paste above')) +end + +---Prompts the user to type a file path and adds it +---@param main_buf number +---@param buf_attach number +AB.prompt_add_attachment = function(main_buf, buf_attach) + vim.ui.input({ + prompt = 'Attachment path: ', + completion = 'file', + }, function(path) + if path and path ~= '' then + AB.add_attachment(main_buf, buf_attach, path) + end + end) +end + +---Adds a file path to the attachments and refreshes the buffer +---@param main_buf number +---@param buf_attach number +---@param path string +---@return boolean +AB.add_attachment = function(main_buf, buf_attach, path) + if not v.nvim_buf_is_valid(main_buf) then + vim.notify('Main buffer is not valid', vim.log.levels.ERROR) + return false + end + + path = vim.fn.expand(path) + if not path:match('^/') then + path = vim.fn.fnamemodify(path, ':p') + end + + local valid, err = u.validate_attachment_file(path) + if not valid then + vim.notify('Cannot attach: ' .. err, vim.log.levels.ERROR) + return false + end + + local ok, attachments = pcall(v.nvim_buf_get_var, main_buf, 'notmuch_attachments') + if not ok then + attachments = {} + end + + for _, existing in ipairs(attachments) do + if existing == path then + vim.notify('File already attached: ' .. path, vim.log.levels.WARN) + return false + end + end + + table.insert(attachments, path) + v.nvim_buf_set_var(main_buf, 'notmuch_attachments', attachments) + + vim.notify('Attached: ' .. vim.fn.fnamemodify(path, ':t'), vim.log.levels.INFO) + + if buf_attach and v.nvim_buf_is_valid(buf_attach) then + AB.refresh_attachment_display(buf_attach, main_buf) + end + + return true +end + +---Shows the attachment window (horizontal split below current window). +---Recreates the attachment buffer if it was killed. +---@param main_buf number +---@param buf_attach number|nil +AB.show_attachment_window = function(main_buf, buf_attach) + -- Recreate buffer if it was killed + if not buf_attach or not v.nvim_buf_is_valid(buf_attach) then + buf_attach = AB.create_attachment_buffer(main_buf) + end + + AB.refresh_attachment_display(buf_attach, main_buf) + + -- Focus existing window if already open + local wins = vim.fn.win_findbuf(buf_attach) + if #wins > 0 then + v.nvim_set_current_win(wins[1]) + return + end + + v.nvim_open_win(buf_attach, true, { + split = 'below', + win = 0, + }) + + -- Ensure modifiable after window opens + vim.bo[buf_attach].modifiable = true + + v.nvim_win_set_cursor(0, { HEADER_LINES + 1, 0 }) +end + +return AB diff --git a/lua/notmuch/attach_cmd.lua b/lua/notmuch/attach_cmd.lua index 596d8a4..d5a2e2f 100644 --- a/lua/notmuch/attach_cmd.lua +++ b/lua/notmuch/attach_cmd.lua @@ -31,6 +31,13 @@ a.attach_handler = function(buf) -- Report success vim.notify(string.format('Attached: %s (%d total)', filepath, #attachments), vim.log.levels.INFO) + + -- Refresh attachment buffer if it exists + local buf_attach_name = 'attachments:' .. buf + local buf_attach = vim.fn.bufnr('^' .. vim.fn.escape(buf_attach_name, '^$.*[]~') .. '$') + if buf_attach ~= -1 then + require('notmuch.attach_buffer').refresh_attachment_display(buf_attach, buf) + end end end @@ -49,7 +56,7 @@ a.remove_handler = function(buf) -- Show error if not found if not found_index then - vim.notify('File not in attachments (check with :AttachList): ' .. filepath, vim.log.levels.ERROR) + vim.notify('File not in attachments: ' .. filepath, vim.log.levels.ERROR) return end @@ -59,6 +66,13 @@ a.remove_handler = function(buf) -- Report success vim.notify(string.format('Removed: %s (%d remaining)', filepath, #attachments), vim.log.levels.INFO) + + -- Refresh attachment buffer if it exists + local buf_attach_name = 'attachments:' .. buf + local buf_attach = vim.fn.bufnr('^' .. vim.fn.escape(buf_attach_name, '^$.*[]~') .. '$') + if buf_attach ~= -1 then + require('notmuch.attach_buffer').refresh_attachment_display(buf_attach, buf) + end end end @@ -70,19 +84,8 @@ end a.list_handler = function(buf) return function() - local attachments = v.nvim_buf_get_var(buf, 'notmuch_attachments') - - if #attachments == 0 then - print('No attachments. Try adding with :Attach') - return - end - - print(string.format('Attachments (%d):', #attachments)) - for i, path in ipairs(attachments) do - local stat = vim.uv.fs_stat(path) - local size_kb = stat and math.floor(stat.size / 1024) or 0 - print(string.format(' [%d] %s (%d KB)', i, path, size_kb)) - end + local ok, buf_attach = pcall(vim.api.nvim_buf_get_var, buf, 'notmuch_attach_buf') + require('notmuch.attach_buffer').show_attachment_window(buf, ok and buf_attach or nil) end end diff --git a/lua/notmuch/config.lua b/lua/notmuch/config.lua index 6d779bd..9174c27 100644 --- a/lua/notmuch/config.lua +++ b/lua/notmuch/config.lua @@ -57,6 +57,7 @@ C.defaults = function() }, suppress_deprecation_warning = false, -- Used for API deprecation warning suppression render_html_body = false, -- True means prioritize displaying rendered HTML + message_order = "newest-first", -- "newest-first" | "oldest-first" - Order of messages in thread view open_handler = function(attachment) require('notmuch.handlers').default_open_handler(attachment) end, diff --git a/lua/notmuch/handlers.lua b/lua/notmuch/handlers.lua index b960c82..bd6e556 100644 --- a/lua/notmuch/handlers.lua +++ b/lua/notmuch/handlers.lua @@ -58,6 +58,13 @@ H.default_view_handler = function(attachment) local filetype = vim.fn.system({ 'file', '--mime-type', '-b', path }):gsub('%s+$', '') local ext = path:match('%.([^%.]+)$') or '' + -- Calendar files (most common) + if filetype:match('text/calendar') or ext:match('^calendar?$') then + return try_commands({ + { tool = 'render-calendar-attachment.py', command = function(p) return { 'render-calendar-attachment.py', p } end }, + }) or "Calendar file (download render-calendar-attachment.py [https://github.com/ceuk/mutt_dotfiles/tree/master/bin] to view)" + end + -- HTML files (most common) if filetype:match('^text/html$') or ext:match('^html?$') then return try_commands({ diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index f0a01f4..d4d85cf 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -197,11 +197,19 @@ nm.show_thread = function(s) threadid = string.match(s, "[0-9a-z]+", 7) end - -- Open buffer if already exists, otherwise create new `buf` - local bufno = vim.fn.bufnr('thread:' .. threadid) + -- Open buffer if already exists and has content, otherwise create new `buf` + -- Match by prefix since the buffer name may include the subject after the thread ID + local bufno = vim.fn.bufnr('^thread:' .. threadid) if bufno ~= -1 then - v.nvim_win_set_buf(0, bufno) - return true + local line_count = v.nvim_buf_line_count(bufno) + local first_line = (line_count > 0) and v.nvim_buf_get_lines(bufno, 0, 1, false)[1] or "" + if line_count > 1 or first_line ~= "" then + -- Buffer exists and has real content, switch to it + v.nvim_win_set_buf(0, bufno) + return true + end + -- Buffer exists but is empty (e.g. from a failed previous load) — wipe and reload + v.nvim_buf_delete(bufno, { force = true }) end local buf = v.nvim_create_buf(true, true) v.nvim_buf_set_name(buf, "thread:" .. threadid) @@ -209,8 +217,19 @@ nm.show_thread = function(s) -- Get output (JSON parsed) and display lines in buffer local lines, metadata = require('notmuch.thread').show_thread(threadid) + if #lines == 0 then + vim.notify('show_thread: no content returned for thread:' .. threadid, vim.log.levels.WARN) + v.nvim_buf_delete(buf, { force = true }) + return nil + end v.nvim_buf_set_lines(buf, 0, -1, false, lines) + -- Rename buffer to include the subject for easier buffer switching + local subject = (metadata.thread or {}).subject or "" + if subject ~= "" then + v.nvim_buf_set_name(buf, "thread:" .. threadid .. " " .. subject) + end + -- Set up buffer-local variables with thread metadata vim.b.notmuch_thread = metadata.thread vim.b.notmuch_messages = metadata.messages diff --git a/lua/notmuch/refresh.lua b/lua/notmuch/refresh.lua index 8a2d353..26fcf2e 100644 --- a/lua/notmuch/refresh.lua +++ b/lua/notmuch/refresh.lua @@ -23,16 +23,59 @@ end -- Refreshes the thread view buffer -- -- This function refreshes the buffer containing a thread view with all its --- messages inside by deleting the original buffer and re-invokes the --- `show_thread()` function again to refresh the thread view. +-- messages inside by re-fetching the thread data and repopulating the current +-- buffer in-place (without wiping and recreating it). -- -- @usage -- -- Normally invoked by pressing `r` in the thread view buffer -- lua require('notmuch.refresh').refresh_thread_buffer() r.refresh_thread_buffer = function() - local thread = string.match(v.nvim_buf_get_name(0), 'thread:%C+') - v.nvim_command('bwipeout') - nm.show_thread(thread) + local bufname = v.nvim_buf_get_name(0) + -- Extract just the thread ID (hex chars after "thread:") + local threadid = string.match(bufname, 'thread:([0-9a-z]+)') + if not threadid then + return + end + + local buf = v.nvim_get_current_buf() + + -- Re-fetch thread data + local lines, metadata = require('notmuch.thread').show_thread(threadid) + + if #lines == 0 then + vim.notify('Thread refresh: no content returned for ' .. threadid, vim.log.levels.WARN) + return + end + + -- Repopulate buffer in-place + vim.bo.modifiable = true + v.nvim_buf_set_lines(buf, 0, -1, false, lines) + + -- Update buffer name (subject may have changed), only if different + local subject = (metadata.thread or {}).subject or "" + local new_name = "thread:" .. threadid + if subject ~= "" then + new_name = new_name .. " " .. subject + end + if v.nvim_buf_get_name(buf) ~= new_name then + pcall(v.nvim_buf_set_name, buf, new_name) + end + + -- Refresh buffer-local metadata + vim.b.notmuch_thread = metadata.thread + vim.b.notmuch_messages = metadata.messages + + -- Re-insert hint line at top + local hint_text = + "Hints: : Toggle fold message | : Next message | : Prev message | q: Close | a: See attachment parts" + v.nvim_buf_set_lines(buf, 0, 0, false, { hint_text, "" }) + + -- Clean up trailing blank line, reset cursor, lock buffer + v.nvim_buf_set_lines(buf, -2, -1, true, {}) + v.nvim_win_set_cursor(0, { 1, 0 }) + vim.bo.modifiable = false + + print("Thread refreshed") end -- Refreshes the notmuch landing page buffer diff --git a/lua/notmuch/send.lua b/lua/notmuch/send.lua index 8e6a941..168833b 100644 --- a/lua/notmuch/send.lua +++ b/lua/notmuch/send.lua @@ -7,18 +7,6 @@ local v = vim.api local config = require('notmuch.config') -- Prompt confirmation for sending an email --- --- This function utilizes vim's builtin `confirm()` to prompt the user and --- confirm the action of sending an email. This is applicable for sending newly --- composed mails or replies by passing the mail file path. --- --- @param filename string: path to the email message you would like to send --- --- @usage --- -- See reply() or compose() --- vim.keymap.set('n', '', function() --- confirm_sendmail(reply_filename) --- end, { buffer = true }) local confirm_sendmail = function() local choice = v.nvim_call_function('confirm', { 'Send email?', @@ -26,115 +14,58 @@ local confirm_sendmail = function() 2 -- Default to no }) - if choice == 1 then - return true - else - return false - end + return choice == 1 end ---- Builds plain text msg from contents into single-part MIME message of main ---- msg buffer and outputs it in place. +--- Builds plain text msg from contents into single-part MIME message. --- ---- If the composed email has no attachments, it makes more sense (cheaper and ---- more idiomatic) to send as a single part (not MIME `multipart/mixed`) of ---- type `text/plain; charset=UTF-8`. +--- Used when the composed email has no attachments. Sends as a single part +--- (not MIME `multipart/mixed`) of type `text/plain; charset=UTF-8`. --- --- @param buf integer: buffer ID of the message compose file local build_plain_msg = function(buf) local main_lines = v.nvim_buf_get_lines(buf, 0, -1, false) - -- Extract attributes and remove from main message buffer `buf` local attributes, msg = m.get_msg_attributes(main_lines) v.nvim_buf_set_lines(buf, 0, -1, false, msg) vim.cmd('silent! write!') - -- Build MIME single-part email: - -- - Header - -- - MIME headers - -- - Blank line - -- - Body local plain_msg = {} - -- Add email headers (To, From, Subject, etc.) for key, value in pairs(attributes) do table.insert(plain_msg, key .. ": " .. value) end - -- Add MIME headers (required for UTF-8 support per RFC2045) table.insert(plain_msg, "MIME-Version: 1.0") table.insert(plain_msg, "Content-Type: text/plain; charset=utf-8") table.insert(plain_msg, "Content-Transfer-Encoding: 8bit") - - -- Add blank line separator (required by RFC5322) table.insert(plain_msg, "") - -- Add message body for _, line in ipairs(msg) do table.insert(plain_msg, line) end - -- Write complete email to file v.nvim_buf_set_lines(buf, 0, -1, false, plain_msg) vim.cmd('silent! write!') end --- Builds mime msg from contents of main msg buffer and attachment buffer -local build_mime_msg = function(buf, buf_attach, compose_filename) - local attach_lines = vim.api.nvim_buf_get_lines(buf_attach, 0, -1, false) - local main_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - - -- Extract headers and body (read-only operation) - local attributes, msg = m.get_msg_attributes(main_lines) - - -- VALIDATE attachments BEFORE modifying buffer/file - -- If validation fails, error is thrown here and buffer remains intact - local attachments = m.create_mime_attachments(attach_lines) - - -- Now safe to modify buffer - attachments are validated - v.nvim_buf_set_lines(buf, 0, -1, false, msg) - vim.cmd('silent! write!') - local mimes = { { - file = compose_filename, - type = "text/plain; charset=utf-8", - } } - - for _, v in ipairs(attachments) do - table.insert(mimes, v) - end - - - - local mime_table = { - version = "Mime-Version: 1.0", - type = "multipart/mixed", -- or multipart/alternative - encoding = "8 bit", - attributes = attributes, - mime = mimes, - } - - - local mime_msg = m.make_mime_msg(mime_table) - v.nvim_buf_set_lines(buf, 0, -1, false, mime_msg) - - - vim.cmd('silent! write!') -end - +--- Builds a multipart MIME message from attachment file paths. +--- +--- @param buf integer: buffer ID of the message compose file +--- @param attachment_paths table: list of absolute file path strings +--- @param message_filename string: path to the composed message file local build_mime_msg_from_attachments = function(buf, attachment_paths, message_filename) local main_lines = v.nvim_buf_get_lines(buf, 0, -1, false) - -- Extract headers and body (read-only operation) local attributes, msg = m.get_msg_attributes(main_lines) - -- VALIDATE attachments BEFORE modifying buffer/file + -- Validate attachments BEFORE modifying buffer/file local attachments = m.create_mime_attachments(attachment_paths) -- Safe to modify buffer now v.nvim_buf_set_lines(buf, 0, -1, false, msg) vim.cmd('silent! write!') - -- Build MIME parts: main body + attachments local mimes = { { file = message_filename, type = "text/plain; charset=utf-8", @@ -158,121 +89,59 @@ local build_mime_msg_from_attachments = function(buf, attachment_paths, message_ vim.cmd('silent! write!') end --- Send a completed message --- --- This function takes a file containing a completed message and send it to the --- recipient(s) using `msmtp`. Typically you will invoke this function after --- confirming from a reply or newly composed email message. The invocation of --- `msmtp` determines by itself the recipient and the sender. --- --- If the configuration `config.options.logfile` is set, then it invokes --- `msmtp` with logging capability to that file. Otherwise, it logs to --- temporary file. --- --- @param filename string: path to the email message you would like to send --- --- @return string: The log message provided by `msmtp` --- --- @usage --- require('notmuch.send').sendmail('/tmp/my_new_email.eml') +-- Send a completed message via msmtp s.sendmail = function(filename) if not vim.loop.fs_stat(filename) then - vim.notify('❌ Email file not found: ' .. filename, vim.log.levels.ERROR) + vim.notify('Email file not found: ' .. filename, vim.log.levels.ERROR) return false end - -- Build msmtp command local cmd_parts = { 'msmtp', '-t', '--read-envelope-from' } if config.options.logfile then table.insert(cmd_parts, '--logfile=' .. vim.fn.shellescape(config.options.logfile)) end local msmtp_cmd = table.concat(cmd_parts, ' ') .. ' <' .. vim.fn.shellescape(filename) - vim.notify('📤 Sending email via msmtp...', vim.log.levels.INFO) + vim.notify('Sending email via msmtp...', vim.log.levels.INFO) - -- Open blank terminal first (reliable PTY handling for interactive input) vim.cmd('botright 15split | terminal') local term_buf = v.nvim_get_current_buf() local term_job = vim.b.terminal_job_id - -- Set up TermClose autocmd BEFORE sending command to avoid race condition - -- Note: Using pattern='*' instead of buffer=term_buf due to Neovim bug where - -- buffer-specific TermClose doesn't fire reliably on terminal buffers local aug = v.nvim_create_augroup('NotmuchSendmail_' .. term_buf, { clear = true }) v.nvim_create_autocmd('TermClose', { group = aug, pattern = '*', once = true, callback = function(ev) - -- Only process TermClose for our specific terminal buffer if ev.buf ~= term_buf then return end - -- Get exit code from v:event.status local exit_code = vim.v.event.status or -1 - -- Defer notification on success because of buffer close redraw if exit_code == 0 then - vim.defer_fn(function() vim.notify('✅ Email sent successfully', vim.log.levels.INFO) end, 500) + vim.defer_fn(function() vim.notify('Email sent successfully', vim.log.levels.INFO) end, 500) else - vim.notify('❌ Failed to send email (exit code: ' .. exit_code .. ')', vim.log.levels.ERROR) + vim.notify('Failed to send email (exit code: ' .. exit_code .. ')', vim.log.levels.ERROR) end end }) - -- Send the command to the terminal, then exit shell to trigger TermClose vim.fn.chansend(term_job, msmtp_cmd .. ' ; exit\n') - - -- Start in insert mode for immediate interaction (e.g. passphrase prompt) vim.cmd('startinsert') return true end --- Reply to an email message --- --- This function uses `notmuch reply` to generate and prepare a reply draft to a --- message by scanning for the `id` of the message you want to reply to. The --- draft file will be stored in `tmp/` and a keymap (default ``) to --- allow sending directly from within nvim --- --- @usage --- -- Typically you would just press `R` on a message in a thread --- require('notmuch.send').reply() -s.reply = function() - -- Get msg id of the mail to be replied to - local id = thread.get_current_message_id() - if not id then return end - - -- Create new draft mail to hold reply - local sanitized_id = id:gsub('/', '-') - local reply_filename = '/tmp/reply-' .. sanitized_id .. '.eml' - - -- Create and edit buffer containing reply file - local buf = v.nvim_create_buf(true, false) - v.nvim_win_set_buf(0, buf) - vim.cmd.edit(reply_filename) - - -- If first time replying, generate draft. Otherwise, no need to duplicate - if not u.file_exists(reply_filename) then - vim.cmd('silent 0read! notmuch reply id:' .. id) - end - - vim.bo.bufhidden = "wipe" -- Automatically wipe buffer when closed - v.nvim_win_set_cursor(0, { 1, 0 }) -- Return cursor to top of file - - -- Initialize attachments variable - -- Sample "attachment" object: - -- { - -- file = 'path/to/attachment', - -- size = uv.fs_stat(), - -- mime = mime.get_mime_type() - -- valid = true or false -- u.validate_attachment_file() - -- } +--- Sets up the attachment buffer and keymaps common to both compose and reply +local setup_attachments = function(buf) vim.api.nvim_buf_set_var(buf, 'notmuch_attachments', {}) - -- Define commands for attachment management (attach_cmd.lua) + local attach_buffer = require('notmuch.attach_buffer') + local buf_attach = attach_buffer.create_attachment_buffer(buf) + + -- :Attach and :AttachRemove commands local attach_cmd = require('notmuch.attach_cmd') v.nvim_buf_create_user_command(buf, 'Attach', attach_cmd.attach_handler(buf), { @@ -289,10 +158,39 @@ s.reply = function() v.nvim_buf_create_user_command(buf, 'AttachList', attach_cmd.list_handler(buf), { nargs = 0, - desc = 'List current email attachments' + desc = 'Open attachment buffer' }) - -- Set keymap for sending + -- Keymap for showing attachment window + vim.keymap.set('n', config.options.keymaps.attachment_window, function() + local ok, current_buf_attach = pcall(v.nvim_buf_get_var, buf, 'notmuch_attach_buf') + attach_buffer.show_attachment_window(buf, ok and current_buf_attach or nil) + end, { buffer = true }) + + return buf_attach +end + +-- Reply to an email message +s.reply = function() + local id = thread.get_current_message_id() + if not id then return end + + local sanitized_id = id:gsub('/', '-') + local reply_filename = '/tmp/reply-' .. sanitized_id .. '.eml' + + local buf = v.nvim_create_buf(true, false) + v.nvim_win_set_buf(0, buf) + vim.cmd.edit(reply_filename) + + if not u.file_exists(reply_filename) then + vim.cmd('silent 0read! notmuch reply id:' .. id) + end + + vim.bo.bufhidden = "wipe" + v.nvim_win_set_cursor(0, { 1, 0 }) + + setup_attachments(buf) + vim.keymap.set('n', config.options.keymaps.sendmail, function() if confirm_sendmail() then local attachments = v.nvim_buf_get_var(buf, 'notmuch_attachments') @@ -309,21 +207,10 @@ s.reply = function() end -- Compose a new email --- --- This function creates a new email for the user to edit, with the standard --- message headers and body. The mail content is stored in `/tmp/` so the user --- can come back to it later if needed. --- --- @param to string: recipient address (optionaal argument) --- --- @usage --- -- Typically you can run this with `:ComposeMail` or pressing `C` --- require('notmuch.send').compose() s.compose = function(to) to = to or '' local compose_filename = vim.fn.tempname() .. '-compose.eml' - -- TODO: Add ability to modify default body message and signature local headers = { 'From: ' .. config.options.from, 'To: ' .. to, @@ -334,31 +221,22 @@ s.compose = function(to) config.options.keymaps.attachment_window .. '". Send with "' .. config.options.keymaps.sendmail .. '".', } - -- Create new buffer local buf = v.nvim_create_buf(true, false) v.nvim_win_set_buf(0, buf) vim.cmd.edit(compose_filename) - -- Populate with header fields (date, to, subject) v.nvim_buf_set_lines(buf, 0, -1, false, headers) - local buf_attach = v.nvim_create_buf(true, true) - - -- Keymap for showing attachment_window - vim.keymap.set('n', config.options.keymaps.attachment_window, function() - vim.api.nvim_open_win(buf_attach, true, { - split = 'left', - win = 0 - }) - end, { buffer = true }) + setup_attachments(buf) - -- Keymap for sending the email vim.keymap.set('n', config.options.keymaps.sendmail, function() if confirm_sendmail() then - if u.empty_attachment_window(buf_attach) then + local attachments = v.nvim_buf_get_var(buf, 'notmuch_attachments') + + if #attachments == 0 then build_plain_msg(buf) else - build_mime_msg(buf, buf_attach, compose_filename) + build_mime_msg_from_attachments(buf, attachments, compose_filename) end s.sendmail(compose_filename) diff --git a/lua/notmuch/thread.lua b/lua/notmuch/thread.lua index 1e90362..4cf8c9e 100644 --- a/lua/notmuch/thread.lua +++ b/lua/notmuch/thread.lua @@ -226,7 +226,8 @@ end --- @param depth number Message depth in the thread chain (0 for root message) --- @param lines table Accumulator array for buffer lines (modified in place) --- @param metadata table Accumulator array for thread metadata for buffer var -local function build_message_lines(msg_node, depth, lines, metadata) +--- @param skip_replies boolean If true, don't process replies recursively (for flattened view) +local function build_message_lines(msg_node, depth, lines, metadata, skip_replies) -- Unpack msg_node into message and list of replies local msg = msg_node[1] local replies = msg_node[2] or {} @@ -285,10 +286,11 @@ local function build_message_lines(msg_node, depth, lines, metadata) attachment_count = select(2, has_attachments(msg.body)), }) - -- Process replies recursively - if replies and #replies > 0 then + -- Process replies recursively (only if not skipping) + if not skip_replies and replies and #replies > 0 then + -- Process replies in original order (maintaining hierarchy) for _, reply_node in ipairs(replies) do - build_message_lines(reply_node, depth + 1, lines, metadata) + build_message_lines(reply_node, depth + 1, lines, metadata, false) end end end @@ -404,6 +406,37 @@ function T.setup_cursor_tracking(bufnr) update_current_message() end +--- Jump to the start_line of the next message in the thread +function T.next_message() + local line = vim.api.nvim_win_get_cursor(0)[1] + local messages = vim.b.notmuch_messages + if not messages then return end + for _, msg in ipairs(messages) do + if msg.start_line > line then + vim.api.nvim_win_set_cursor(0, { msg.start_line, 0 }) + return + end + end +end + +--- Jump to the start_line of the previous message in the thread +function T.prev_message() + local line = vim.api.nvim_win_get_cursor(0)[1] + local messages = vim.b.notmuch_messages + if not messages then return end + local target = nil + for _, msg in ipairs(messages) do + if msg.start_line < line then + target = msg + else + break + end + end + if target then + vim.api.nvim_win_set_cursor(0, { target.start_line, 0 }) + end +end + --- Fetches and renders a thread as buffer lines --- --- Runs `notmuch show --format=json` to fetch the thread, parses the JSON @@ -471,8 +504,44 @@ T.show_thread = function(threadid) -- Build buffer lines (also builds accumulated thread metadata) local lines = {} - for _, node in ipairs(thread) do - build_message_lines(node, 0, lines, metadata) + + -- Check if we need to sort messages by timestamp + local config = require('notmuch.config') + local should_reverse = config.options.message_order == "newest-first" + + if should_reverse then + -- Flatten all messages in the thread tree with their timestamps + local function flatten_messages(nodes, depth, flat_list) + for _, node in ipairs(nodes) do + local msg = node[1] + local replies = node[2] or {} + table.insert(flat_list, { + node = node, + depth = depth, + timestamp = msg.timestamp or 0 + }) + if #replies > 0 then + flatten_messages(replies, depth + 1, flat_list) + end + end + end + + -- Flatten all messages + local flat_messages = {} + flatten_messages(thread, 0, flat_messages) + + -- Sort by timestamp (newest first) + table.sort(flat_messages, function(a, b) return a.timestamp > b.timestamp end) + + -- Build lines for sorted messages (with depth set to 0 to remove indentation, skip_replies = true) + for _, item in ipairs(flat_messages) do + build_message_lines(item.node, 0, lines, metadata, true) + end + else + -- Process messages in original order (oldest first, maintaining hierarchy) + for _, node in ipairs(thread) do + build_message_lines(node, 0, lines, metadata, false) + end end -- Set metadata tags based on ordered list of seen tags during recursion diff --git a/syntax/notmuch-attachments.vim b/syntax/notmuch-attachments.vim new file mode 100644 index 0000000..942f736 --- /dev/null +++ b/syntax/notmuch-attachments.vim @@ -0,0 +1,28 @@ +" Syntax highlighting for notmuch-attachments buffer +" Oil.nvim-style: header + file paths as lines + +if exists("b:current_syntax") + finish +endif + +" Header +syntax match NotmuchAttachHeader /^═.*═$/ +highlight link NotmuchAttachHeader Title + +" Hints line +syntax match NotmuchAttachHints /^Hints:.*/ +highlight link NotmuchAttachHints Comment + +" Separator +syntax match NotmuchAttachSeparator /^───.*$/ +highlight link NotmuchAttachSeparator Comment + +" File paths (lines after separator) - absolute paths +syntax match NotmuchAttachPath /^\/.*/ +highlight link NotmuchAttachPath Directory + +" File paths - relative paths (starting with ~/ or ./) +syntax match NotmuchAttachRelPath /^[~.]\/.*/ +highlight link NotmuchAttachRelPath Directory + +let b:current_syntax = "notmuch-attachments"