From 6010880b54ad66e3dd6d75e6655a4ec5e3f9a8fe Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Mon, 19 Jan 2026 15:13:42 +0100 Subject: [PATCH 01/23] feat(view): add support for viewing calendar file attachments Detects calendar files by MIME type or extension and attempts to render them using the external script `render-calendar-attachment.py`. If the script is not available, a message is shown with a link to download it. This improves the handling of .ics and similar calendar attachments in the default view handler. --- lua/notmuch/handlers.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lua/notmuch/handlers.lua b/lua/notmuch/handlers.lua index 67346a2..f8521e9 100644 --- a/lua/notmuch/handlers.lua +++ b/lua/notmuch/handlers.lua @@ -53,6 +53,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({ From 3c83f8b92ef40a0101c1a3f544a5ad19dc8c6985 Mon Sep 17 00:00:00 2001 From: anonymousgrasshopper Date: Sat, 10 Jan 2026 19:00:22 +0100 Subject: [PATCH 02/23] feat: add ability to supply email adress to Inbox command --- lua/notmuch/init.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index 9ec856b..bf7f88a 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -30,7 +30,13 @@ nm.setup = function(opts) -- Set up the main entry point command :Notmuch vim.cmd[[command Notmuch :lua require('notmuch').notmuch_hello()]] - vim.cmd[[command Inbox :lua require('notmuch').search_terms("tag:inbox")]] + vim.api.nvim_create_user_command("Inbox", function(arg) + if arg.fargs ~= {} then + nm.search_terms("tag:inbox to:" .. arg.args) + else + nm.search_terms("tag:inbox") + end + end, { desc = "Open inbox", nargs = "?", complete = "custom,notmuch#CompAddress" }) end -- Launch `notmuch.nvim` landing page From d2b313af69398b24768ed14b2d7b27e4ffdf82f8 Mon Sep 17 00:00:00 2001 From: anonymousgrasshopper Date: Sat, 17 Jan 2026 10:25:37 +0100 Subject: [PATCH 03/23] fix: cannot compare tables in Inbox command --- lua/notmuch/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index bf7f88a..c0823db 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -31,7 +31,7 @@ nm.setup = function(opts) -- Set up the main entry point command :Notmuch vim.cmd[[command Notmuch :lua require('notmuch').notmuch_hello()]] vim.api.nvim_create_user_command("Inbox", function(arg) - if arg.fargs ~= {} then + if #arg.fargs ~= 0 then nm.search_terms("tag:inbox to:" .. arg.args) else nm.search_terms("tag:inbox") From 18e9a3e71a807b8ff599fe438184ba6b329d6307 Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Sun, 1 Feb 2026 12:07:23 +0700 Subject: [PATCH 04/23] style: apply consistent formatting to init.lua Formatter fixed mixed indentation (tabs vs spaces) and inconsistent spacing throughout the file to match project style guidelines. --- lua/notmuch/init.lua | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index c0823db..cfd4823 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -29,14 +29,14 @@ nm.setup = function(opts) end -- Set up the main entry point command :Notmuch - vim.cmd[[command Notmuch :lua require('notmuch').notmuch_hello()]] - vim.api.nvim_create_user_command("Inbox", function(arg) - if #arg.fargs ~= 0 then - nm.search_terms("tag:inbox to:" .. arg.args) - else - nm.search_terms("tag:inbox") - end - end, { desc = "Open inbox", nargs = "?", complete = "custom,notmuch#CompAddress" }) + vim.cmd [[command Notmuch :lua require('notmuch').notmuch_hello()]] + vim.api.nvim_create_user_command("Inbox", function(arg) + if #arg.fargs ~= 0 then + nm.search_terms("tag:inbox to:" .. arg.args) + else + nm.search_terms("tag:inbox") + end + end, { desc = "Open inbox", nargs = "?", complete = "custom,notmuch#CompAddress" }) end -- Launch `notmuch.nvim` landing page @@ -88,8 +88,9 @@ nm.search_terms = function(search, jumptothreadid) v.nvim_buf_set_name(buf, search) v.nvim_win_set_buf(0, buf) - local hint_text = "Hints: : Open thread | q: Close | r: Refresh | %: Sync maildir | a: Archive | A: Archive and Read | +/-/=: Add, remove, toggle tag | o: Sort | dd: Delete" - v.nvim_buf_set_lines(buf, 0, 2, false, { hint_text , "" }) + local hint_text = + "Hints: : Open thread | q: Close | r: Refresh | %: Sync maildir | a: Archive | A: Archive and Read | +/-/=: Add, remove, toggle tag | o: Sort | dd: Delete" + v.nvim_buf_set_lines(buf, 0, 2, false, { hint_text, "" }) -- Async notmuch search to make the UX non blocking require('notmuch.async').run_notmuch_search(search, buf, function() @@ -177,13 +178,14 @@ nm.show_thread = function(s) require('notmuch.util').process_msgs_in_thread(buf) -- Insert hint message at the top of the buffer - 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 , "" }) + 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, "" }) -- Place cursor at head of buffer and prepare display and disable modification v.nvim_buf_set_lines(buf, -3, -1, true, {}) - v.nvim_win_set_cursor(0, { 1, 0}) - vim.bo.filetype="mail" + v.nvim_win_set_cursor(0, { 1, 0 }) + vim.bo.filetype = "mail" vim.bo.modifiable = false end @@ -198,7 +200,7 @@ end -- @usage -- lua require('notmuch').count('tag:inbox') -- > '999' nm.count = function(search) - local db = require'notmuch.cnotmuch'(config.options.notmuch_db_path, 0) + local db = require 'notmuch.cnotmuch' (config.options.notmuch_db_path, 0) local q = db.create_query(search) local count_threads = q.count_threads() db.close() @@ -215,7 +217,7 @@ end -- nm.show_all_tags() -- opens the `hello` page nm.show_all_tags = function() -- Fetch all tags available in the notmuch database - local db = require'notmuch.cnotmuch'(config.options.notmuch_db_path, 0) + local db = require 'notmuch.cnotmuch' (config.options.notmuch_db_path, 0) local tags = db.get_all_tags() db.close() @@ -227,10 +229,10 @@ nm.show_all_tags = function() -- Insert help hints at the top of the buffer local hint_text = "Hints: : Show threads | q: Close | r: Refresh | %: Refresh maildir | c: Count messages" - v.nvim_buf_set_lines(buf, 0, 0, false, { hint_text , "" }) + v.nvim_buf_set_lines(buf, 0, 0, false, { hint_text, "" }) -- Clean up the buffer and set the cursor to the head - v.nvim_win_set_cursor(0, { 3, 0}) + v.nvim_win_set_cursor(0, { 3, 0 }) v.nvim_buf_set_lines(buf, -2, -1, true, {}) vim.bo.filetype = "notmuch-hello" vim.bo.modifiable = false From 0b1ab7059a35d964cf4d99c8662ba6f339cc983c Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Sun, 1 Feb 2026 12:13:52 +0700 Subject: [PATCH 05/23] docs: document email address argument for Inbox command Added documentation for the new optional email address parameter in the :Inbox command, which allows filtering inbox by recipient address with autocomplete support. Updated CHANGELOG.md, README.md, and doc/notmuch.txt to reflect the new functionality. --- CHANGELOG.md | 2 ++ README.md | 11 +++++++++++ doc/notmuch.txt | 12 +++++++++--- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d025bc..5282010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Automatic notmuch version detection at module load with result caching - Warning notification when using deprecated API (notmuch < 0.32) - Configuration option `suppress_deprecation_warning` to suppress API deprecation warnings +- Optional email address argument for `:Inbox` command to filter by recipient (`to:` field) + - Includes autocomplete for email addresses from notmuch database ### Changed diff --git a/README.md b/README.md index 6324a04..088ec7c 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,17 @@ Here are the core commands within Notmuch.nvim: :NmSearch tag:inbox and date:today ``` +- **`:Inbox [email]`**: Quick access to your inbox. Optionally filter by + recipient email address (Useful for multi-account setups.) + + ```vim + " Open all inbox messages + :Inbox + + " Open inbox for a specific account + :Inbox work@example.com + ``` + ## 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 377f1f6..bc90d5f 100644 --- a/doc/notmuch.txt +++ b/doc/notmuch.txt @@ -215,9 +215,15 @@ Notmuch.nvim plugin and the local mail system to search and browse. search term you want. *:Inbox* -:Inbox Launches into the user's mail inbox showing all threads - with the `inbox` tag. Essentially implemented by the - |NmSearch| command. +:Inbox [email] Launches into the user's mail inbox showing all threads + with the `inbox` tag. Optionally accepts an email address + to filter results by recipient (using notmuch's `to:` + search term). For example: > + + :Inbox work@example.com +< + Provides autocomplete for email addresses from the notmuch + database. Essentially implemented by the |NmSearch| command. ------------------------------------------------------------------------------ OPTIONS *notmuch-options* From bb6917b2f4ede2eaaadfe452702d12a8a0e6af2b Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Wed, 14 Jan 2026 20:33:04 +0300 Subject: [PATCH 06/23] refactor(thread): migrate to JSON parsing for thread display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace brittle regex-based text parsing with structured JSON parsing for displaying email threads. This provides more reliable MIME handling and enables new features. Changes: - Add thread.lua module with JSON-based thread processing - Replace `notmuch show | col` with `notmuch show --format=json` - Handle complex MIME structures (multipart/mixed, multipart/alternative) - Concatenate multiple inline text parts (body, signatures, footers) - Show MIME markers for attachments and HTML content New features: - Per-message tag display in thread headers - Attachment count indicator (📎) in message headers - Proper handling of text/plain attachments vs inline content Deprecates util.process_msgs_in_thread() in favor of thread.show_thread() --- CHANGELOG.md | 12 ++ lua/notmuch/init.lua | 11 +- lua/notmuch/thread.lua | 242 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 lua/notmuch/thread.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 5282010..1aeb598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- New `thread.lua` module for JSON-based thread display parsing +- Per-message tag display in thread view header +- Attachment count indicator (📎) in message headers +- MIME part markers in message body for attachments and HTML content - FFI bindings for `notmuch_database_open_with_config` (notmuch 5.4+/API 0.32+) - Automatic notmuch version detection at module load with result caching - Warning notification when using deprecated API (notmuch < 0.32) @@ -18,9 +22,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Thread display now uses `notmuch show --format=json` instead of raw text parsing + - More robust parsing immune to text format changes + - Proper handling of complex MIME structures (multipart/mixed, multipart/alternative) + - Concatenates multiple inline text parts (body, signatures, mailing list footers) - Database opening now uses `notmuch_database_open_with_config` when available, with fallback to deprecated `notmuch_database_open` - Version detection runs once at module load for improved performance +### Deprecated + +- `util.process_msgs_in_thread()` - replaced by `thread.show_thread()` with JSON parsing + ### Fixed - Correct notmuch version number detection (checks API version 0.32 instead of library version 5.4) diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index cfd4823..dc2f2aa 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -172,10 +172,9 @@ nm.show_thread = function(s) local buf = v.nvim_create_buf(true, true) v.nvim_buf_set_name(buf, "thread:" .. threadid) v.nvim_win_set_buf(0, buf) - v.nvim_command("silent 0read! notmuch show --exclude=false thread:" .. threadid .. " | col") - -- Clean up the messages in the thread to display in UI friendly way - require('notmuch.util').process_msgs_in_thread(buf) + local lines = require('notmuch.thread').show_thread(threadid) + v.nvim_buf_set_lines(buf, 0, -1, false, lines) -- Insert hint message at the top of the buffer local hint_text = @@ -183,9 +182,9 @@ nm.show_thread = function(s) v.nvim_buf_set_lines(buf, 0, 0, false, { hint_text, "" }) -- Place cursor at head of buffer and prepare display and disable modification - v.nvim_buf_set_lines(buf, -3, -1, true, {}) - v.nvim_win_set_cursor(0, { 1, 0 }) - vim.bo.filetype = "mail" + v.nvim_buf_set_lines(buf, -2, -1, true, {}) + v.nvim_win_set_cursor(0, { 1, 0}) + vim.bo.filetype="mail" vim.bo.modifiable = false end diff --git a/lua/notmuch/thread.lua b/lua/notmuch/thread.lua new file mode 100644 index 0000000..3f77476 --- /dev/null +++ b/lua/notmuch/thread.lua @@ -0,0 +1,242 @@ +local T = {} + +--- Recursively checks MIME tree for parts with filenames (attachments) +--- @param body table Array of body part objects from notmuch JSON output +--- @return boolean, integer +local function has_attachments(body) + if not body or #body == 0 then + return false, 0 + end + + local count = 0 + + -- Recursively walk the MIME tree and check for `filename` for attachments + local function walk(parts) + for _, part in ipairs(parts) do + if part.filename then + count = count + 1 + end + if type(part.content) == "table" then + walk(part.content) + end + end + end + + walk(body) + return count > 0, count +end + +--- Indents a summary line based on depth in thread reply chain +--- @param line string Line to indent +--- @param depth number Reply depth (0 for root) +--- @return string indented Indented line with depth prefix +local function indent_line(line, depth) + if depth == 0 then + return line + end + return string.rep('────', depth) .. line +end + +--- Formats message headers into buffer lines +--- Creates the summary line and detailed headers +--- @param msg table Message object from notmuch JSON output +--- @param depth number Message depth in thread chain for indentation +--- @return table Array of formatted header lines +local function format_headers(msg, depth) + local lines = {} + local headers = msg.headers or {} + + -- Extract header values with fallbacks + local from = headers.From or "[Unknown sender]" + local subject = headers.Subject or "[No subject]" + local to = headers.To or "" + local cc = headers.Cc + local date_full = headers.Date or "" + local date_relative = msg.date_relative or "" + + -- Get tags and attachment info + local tags = msg.tags or {} + local tags_str = table.concat(tags, " ") + local has_attach, attach_count = has_attachments(msg.body) + + -- Format summary line with indendation depth indicator + local summary = string.format("%s (%s) (%s)", from, date_relative, tags_str) + table.insert(lines, indent_line(summary, depth)) + + -- Fold start marker with message ID + table.insert(lines, string.format("id:%s {{{", msg.id)) + + -- Add detailed headers + table.insert(lines, "Subject: " .. subject) + table.insert(lines, "From: " .. from) + if to ~= "" then + table.insert(lines, "To: " .. to) + end + if cc then + table.insert(lines, "Cc: " .. cc) + end + table.insert(lines, "Date: " .. date_full) + + -- Add attachment indicator if applicable + if has_attach then + table.insert(lines, string.format("📎 %d attachment%s", attach_count, attach_count > 1 and "s" or "")) + end + + -- Blank link after headers + table.insert(lines, "") + + return lines +end + +--- Processes the MIME body parts and adds them to buffer lines +--- +--- Walks the MIME tree and handles each part type appropriately: +--- - multipart/*: Recurses into child parts +--- - text/* (inline): Adds content directly +--- - attachments (with filename): Adds marker with filename and hint +--- - other inline (images, etc): Adds type marker +--- +--- @param body table MIME part objects from notmuch JSON output +--- @return table lines Array of buffer lines for the message body +local function process_body_parts(body) + local lines = {} + + local function walk(parts, parent_type) + for _, part in ipairs(parts) do + local content_type = part['content-type'] or '' + + if content_type:match('^multipart/') then + -- Multipart envelope -> recurse through child parts + walk(part.content, content_type) + + elseif part.filename then + -- Definitely an attachment -> display to user and hint for viewing + table.insert(lines, string.format( + "[ 📎 %s (%s) - press 'a' to view attachments ]", + part.filename, content_type + )) + table.insert(lines, "") + + elseif content_type == 'text/plain' and part.content then + -- Always show inline plain text (including signatures, etc.) + for _, line in ipairs(vim.split(part.content, '\n', { plain = true })) do + table.insert(lines, line) + end + + elseif content_type == 'text/html' then + -- In multipart/alternative, skip HTML (plain text is preferred) + -- Otherwise, show marker for standalone HTML + if parent_type == 'multipart/alternative' then + table.insert(lines, "[ text/html (alternative) - press 'a' to view ]") + table.insert(lines, "") + else + table.insert(lines, "[ text/html (hidden) - press 'a' to view ]") + table.insert(lines, "") + end + + elseif part.content then + table.insert(lines, string.format("[ %s (inline) - press 'a' to view attachments ]", content_type)) + table.insert(lines, "") + end + end + end + + walk(body, nil) + return lines +end + +--- Recursively processes a message and its replies into buffer lines +--- @param msg_node table Message node from notmuch JSON: [msg, [replies]] +--- @param depth number Message depth in the thread chain (0 for root message) +--- @param lines table Accumulator array for buffer lines (modified in place) +local function build_message_lines(msg_node, depth, lines) + -- Unpack msg_node into message and list of replies + local msg = msg_node[1] + local replies = msg_node[2] or {} + + -- Parse and prepare header lines + local headers = format_headers(msg, depth) + vim.list_extend(lines, headers) + + -- Extract body and content + local body = process_body_parts(msg.body) + vim.list_extend(lines, body) + + -- Add fold end marker + table.insert(lines, "}}}") + + -- Add blank line separator after message + table.insert(lines, "") + + -- Process replies recursively + if replies and #replies > 0 then + for _, reply_node in ipairs(replies) do + build_message_lines(reply_node, depth + 1, lines) + end + end +end + +T.show_thread = function(threadid) + -- Run `notmuch show` with JSON format + local res = vim.system({ + 'notmuch', 'show', + '--format=json', + '--exclude=false', + 'thread:' .. threadid + }):wait() + + -- Check for `notmuch show` execution error + if res.code ~= 0 then + vim.notify( + 'Error running notmuch show: ' .. (res.stderr or 'unknown error'), + vim.log.levels.ERROR + ) + return { "Error: Could not fetch thread data" } + end + + -- Check for empty result output + if res.stdout == "[]\n" or res.stdout == "" then + return { "Thread not found or empty" } + end + + -- Parse/decode JSON output + local ok, json = pcall(vim.json.decode, res.stdout) + if not ok then + vim.notify( + 'Failed to parse thread JSON: ' .. tostring(json), + vim.log.levels.ERROR + ) + return { "Error: Could not parse thread data" } + end + + -- Validate JSON structure + if not json or #json == 0 or not json[1] or #json[1] == 0 or not json[1][1] then + return { "Thread data is malformed or empty" } + end + + -- Extract root message node: + -- [ -- Array of threads + -- [ -- Thread (single element for `notmuch show thread:X`) + -- [ -- Message tree (array of messages at same depth) + -- { -- Message object (root message) + -- "id": "...", + -- "headers": {...}, + -- "body": [...], + -- "replies": [...] + -- } + -- ] + -- ] + -- ] + -- + -- TL;DR: + -- json[1][1] is the root node -> [message_object, [replies_array]] + local root_node = json[1][1] + + -- Build buffer lines + local lines = {} + build_message_lines(root_node, 0, lines) + + return lines +end + +return T From b9fc1822e16c099e20f4eef2b30abe4e2d30b1dd Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Thu, 15 Jan 2026 19:37:41 +0300 Subject: [PATCH 07/23] feat(thread): add optional HTML body rendering via w3m Add support for rendering HTML email bodies in thread view using w3m. This is useful for multipart/alternative emails where HTML content is often richer than the plain text alternative. - Add `render_html_body` config option (default: false) - Add `render_html()` function with w3m integration - Graceful fallback when w3m is not installed or fails - Fetch HTML content from notmuch with `--include-html` flag - Add module-level documentation to thread.lua --- CHANGELOG.md | 4 ++ lua/notmuch/config.lua | 1 + lua/notmuch/thread.lua | 112 +++++++++++++++++++++++++++++------------ 3 files changed, 86 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aeb598..6fc81a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- HTML body rendering via `w3m` for `multipart/alternative` emails + - New `render_html_body` config option (default: `false`) + - Graceful fallback when `w3m` is not installed - New `thread.lua` module for JSON-based thread display parsing - Per-message tag display in thread view header - Attachment count indicator (📎) in message headers @@ -26,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - More robust parsing immune to text format changes - Proper handling of complex MIME structures (multipart/mixed, multipart/alternative) - Concatenates multiple inline text parts (body, signatures, mailing list footers) + - Now fetches HTML content (`--include-html`) for optional rendering - Database opening now uses `notmuch_database_open_with_config` when available, with fallback to deprecated `notmuch_database_open` - Version detection runs once at module load for improved performance diff --git a/lua/notmuch/config.lua b/lua/notmuch/config.lua index 6b74216..6d779bd 100644 --- a/lua/notmuch/config.lua +++ b/lua/notmuch/config.lua @@ -56,6 +56,7 @@ C.defaults = function() -- terminal: Real PTY terminal with stdin support for GPG/OAuth prompts }, suppress_deprecation_warning = false, -- Used for API deprecation warning suppression + render_html_body = false, -- True means prioritize displaying rendered HTML open_handler = function(attachment) require('notmuch.handlers').default_open_handler(attachment) end, diff --git a/lua/notmuch/thread.lua b/lua/notmuch/thread.lua index 3f77476..a44620a 100644 --- a/lua/notmuch/thread.lua +++ b/lua/notmuch/thread.lua @@ -1,4 +1,32 @@ +--- notmuch.thread -- Thread display module for notmuch.nvim +--- +--- Fetches thread data via `notmuch show --format=json` and transforms it into +--- buffer lines with fold markers, formatted headers, and rendered body +--- content. +--- +--- Handles MIME tree walking for multipart messages, with optional HTML +--- rendering via w3m when `config.options.render_html_body` is enabled. +--- +--- Anatomy of a thread object from `notmuch show --format=json` output: +--- +--- [ -- Array of threads +--- [ -- Thread (single element for `notmuch show thread:X`) +--- [ -- Message tree (array of messages at same depth) +--- { -- Message object (root message) +--- "id": "...", +--- "headers": {...}, +--- "body": [...], +--- }, +--- [...] -- Replies array (recursive nodes) +--- ] +--- ] +--- ] +--- +--- TL;DR: +--- json[1][1] is the root *node* -> [message_object, [replies_array]] + local T = {} +local config = require('notmuch.config') --- Recursively checks MIME tree for parts with filenames (attachments) --- @param body table Array of body part objects from notmuch JSON output @@ -88,6 +116,32 @@ local function format_headers(msg, depth) return lines end +--- Renders HTML body content and returns as lines +--- @param raw string Raw HTML content from email body part +--- @return table rendered Rendered HTML ready for buffer display +local function render_html(raw) + -- Check if `w3m` is installed and in $PATH for user (otherwise render fails) + if vim.fn.executable('w3m') ~= 1 then + return { "[ w3m not installed - press 'a' to view attachments ]" } + end + + -- Run w3m to render the `raw` HTML content + local ok, res = pcall(function() + return vim.system({ 'w3m', '-T', 'text/html', '-dump' }, { + text = true, + stdin = raw, + }):wait() + end) + + -- Check for error. Return UX hint rather than vim error + if not ok or res.code ~= 0 then + return { "[ Failed to render HTML - press 'a' to view attachments ]" } + end + + -- Return table of rendered HTML with trimmed empty lines at start/end + return vim.split(res.stdout or '', '\n', { plain = true, trimempty = true }) +end + --- Processes the MIME body parts and adds them to buffer lines --- --- Walks the MIME tree and handles each part type appropriately: @@ -108,7 +162,6 @@ local function process_body_parts(body) if content_type:match('^multipart/') then -- Multipart envelope -> recurse through child parts walk(part.content, content_type) - elseif part.filename then -- Definitely an attachment -> display to user and hint for viewing table.insert(lines, string.format( @@ -116,24 +169,29 @@ local function process_body_parts(body) part.filename, content_type )) table.insert(lines, "") - elseif content_type == 'text/plain' and part.content then - -- Always show inline plain text (including signatures, etc.) - for _, line in ipairs(vim.split(part.content, '\n', { plain = true })) do - table.insert(lines, line) - end - - elseif content_type == 'text/html' then - -- In multipart/alternative, skip HTML (plain text is preferred) - -- Otherwise, show marker for standalone HTML - if parent_type == 'multipart/alternative' then - table.insert(lines, "[ text/html (alternative) - press 'a' to view ]") + if parent_type ~= 'multipart/alternative' or not config.options.render_html_body then + -- Always show inline plain text (including signatures, etc.) + for _, line in ipairs(vim.split(part.content, '\n', { plain = true })) do + table.insert(lines, line) + end table.insert(lines, "") - else - table.insert(lines, "[ text/html (hidden) - press 'a' to view ]") + end + elseif content_type == 'text/html' and part.content then + if not config.options.render_html_body then + -- User prefers plain text output. Hide HTML content with hint marker + if parent_type == 'multipart/alternative' then + table.insert(lines, "[ text/html (alternative) - press 'a' to view ]") + table.insert(lines, "") + else + table.insert(lines, "[ text/html (hidden) - press 'a' to view ]") + table.insert(lines, "") + end + else -- config.options.render_html_body == true + local html_content = render_html(part.content) + vim.list_extend(lines, html_content) table.insert(lines, "") end - elseif part.content then table.insert(lines, string.format("[ %s (inline) - press 'a' to view attachments ]", content_type)) table.insert(lines, "") @@ -176,12 +234,20 @@ local function build_message_lines(msg_node, depth, lines) end end +--- Fetches and renders a thread as buffer lines +--- +--- Runs `notmuch show --format=json` to fetch the thread, parses the JSON +--- output, and transforms it into formatted buffer lines with fold markers. +--- +--- @param threadid string Thread ID (without 'thread:' prefix) +--- @return table lines Array of strings ready for buffer display T.show_thread = function(threadid) -- Run `notmuch show` with JSON format local res = vim.system({ 'notmuch', 'show', '--format=json', '--exclude=false', + '--include-html', 'thread:' .. threadid }):wait() @@ -214,22 +280,6 @@ T.show_thread = function(threadid) return { "Thread data is malformed or empty" } end - -- Extract root message node: - -- [ -- Array of threads - -- [ -- Thread (single element for `notmuch show thread:X`) - -- [ -- Message tree (array of messages at same depth) - -- { -- Message object (root message) - -- "id": "...", - -- "headers": {...}, - -- "body": [...], - -- "replies": [...] - -- } - -- ] - -- ] - -- ] - -- - -- TL;DR: - -- json[1][1] is the root node -> [message_object, [replies_array]] local root_node = json[1][1] -- Build buffer lines From b9fc36298f3d311326c92b88d8f7b1bffa075c9e Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Fri, 16 Jan 2026 16:15:37 +0300 Subject: [PATCH 08/23] feat(thread): export thread metadata to buffer-local variable Add `vim.b.notmuch_thread` buffer variable containing thread-level metadata for extensibility (statusline integration, custom scripts). Exported fields: - id: Thread ID for notmuch queries - subject: Thread subject from root message - date_relative: Root message date - message_count: Total messages in thread - tags: Union of all message tags (sorted) - authors: Unique participants (full From header, deduplicated) Implementation: - Accumulate metadata during `build_message_lines()` tree traversal - Return (lines, metadata) tuple from `show_thread()` - Caller sets buffer variable after creating buffer This is the first of several buffer variables that will enable O(1) message lookup and rich statusline integration. --- lua/notmuch/init.lua | 3 +- lua/notmuch/thread.lua | 81 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index dc2f2aa..8da66e6 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -173,8 +173,9 @@ nm.show_thread = function(s) v.nvim_buf_set_name(buf, "thread:" .. threadid) v.nvim_win_set_buf(0, buf) - local lines = require('notmuch.thread').show_thread(threadid) + local lines, metadata = require('notmuch.thread').show_thread(threadid) v.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.b.notmuch_thread = metadata -- Insert hint message at the top of the buffer local hint_text = diff --git a/lua/notmuch/thread.lua b/lua/notmuch/thread.lua index a44620a..649437f 100644 --- a/lua/notmuch/thread.lua +++ b/lua/notmuch/thread.lua @@ -24,10 +24,23 @@ --- --- TL;DR: --- json[1][1] is the root *node* -> [message_object, [replies_array]] +--- +--- During the fetching and parsing of thread JSON data, some useful information +--- is stored in buffer-local variables for user extensibility (e.g. statusline +--- integration and so on): +--- +--- `vim.b.notmuch_thread` : Current thread metadata +--- `vim.b.notmuch_thread_messages` : Array of message objects inside thread +--- `vim.b.notmuch_current` : Cursor-tracked current message data +--- `vim.b.notmuch_status` : Formatted string for quick statusline local T = {} local config = require('notmuch.config') +-------------------------------------------------------------------------------- +-- PRIVATE: Thread parsing helpers +-------------------------------------------------------------------------------- + --- Recursively checks MIME tree for parts with filenames (attachments) --- @param body table Array of body part objects from notmuch JSON output --- @return boolean, integer @@ -207,11 +220,26 @@ end --- @param msg_node table Message node from notmuch JSON: [msg, [replies]] --- @param depth number Message depth in the thread chain (0 for root message) --- @param lines table Accumulator array for buffer lines (modified in place) -local function build_message_lines(msg_node, depth, lines) +--- @param metadata table Accumulator array for thread metadata for buffer var +local function build_message_lines(msg_node, depth, lines, metadata) -- Unpack msg_node into message and list of replies local msg = msg_node[1] local replies = msg_node[2] or {} + -- Update thread metadata with this message metadata + metadata.message_count = metadata.message_count + 1 + + -- Update tags seen in the thread + for _, tag in ipairs(msg.tags) do + metadata._tags_set[tag] = true + end + + -- Add author into metadata list + local from = (msg.headers or {}).From + if from and from ~= "" then + metadata._authors_seen[from] = true + end + -- Parse and prepare header lines local headers = format_headers(msg, depth) vim.list_extend(lines, headers) @@ -229,11 +257,21 @@ local function build_message_lines(msg_node, depth, lines) -- Process replies recursively if replies and #replies > 0 then for _, reply_node in ipairs(replies) do - build_message_lines(reply_node, depth + 1, lines) + build_message_lines(reply_node, depth + 1, lines, metadata) end end end +-------------------------------------------------------------------------------- +-- PRIVATE: Buffer variable builders +-------------------------------------------------------------------------------- + + + +-------------------------------------------------------------------------------- +-- PUBLIC: Show thread main entry point +-------------------------------------------------------------------------------- + --- Fetches and renders a thread as buffer lines --- --- Runs `notmuch show --format=json` to fetch the thread, parses the JSON @@ -241,6 +279,7 @@ end --- --- @param threadid string Thread ID (without 'thread:' prefix) --- @return table lines Array of strings ready for buffer display +--- @return table thread_metadata Thread metadata to be exported to buffer var T.show_thread = function(threadid) -- Run `notmuch show` with JSON format local res = vim.system({ @@ -257,12 +296,12 @@ T.show_thread = function(threadid) 'Error running notmuch show: ' .. (res.stderr or 'unknown error'), vim.log.levels.ERROR ) - return { "Error: Could not fetch thread data" } + return { "Error: Could not fetch thread data" }, {} end -- Check for empty result output if res.stdout == "[]\n" or res.stdout == "" then - return { "Thread not found or empty" } + return { "Thread not found or empty" }, {} end -- Parse/decode JSON output @@ -272,21 +311,43 @@ T.show_thread = function(threadid) 'Failed to parse thread JSON: ' .. tostring(json), vim.log.levels.ERROR ) - return { "Error: Could not parse thread data" } + return { "Error: Could not parse thread data" }, {} end -- Validate JSON structure if not json or #json == 0 or not json[1] or #json[1] == 0 or not json[1][1] then - return { "Thread data is malformed or empty" } + return { "Thread data is malformed or empty" }, {} end local root_node = json[1][1] - - -- Build buffer lines + local root_msg = root_node[1] + + -- Initialize `vim.b.notmuch_thread` accumulator + local thread_metadata = { + id = threadid, + subject = (root_msg.headers or {}).Subject or "[No subject]", + date_relative = root_msg.date_relative or "", + message_count = 0, + tags = {}, + _tags_set = {}, -- temporary: set for deduplication + authors = {}, + _authors_seen = {}, -- temporary: set for deduplication + } + + -- Build buffer lines (also builds accumulated thread metadata) local lines = {} - build_message_lines(root_node, 0, lines) + build_message_lines(root_node, 0, lines, thread_metadata) - return lines + -- Set metadata tags based on ordered list of seen tags during recursion + thread_metadata.tags = vim.tbl_keys(thread_metadata._tags_set) + table.sort(thread_metadata.tags) + thread_metadata._tags_set = nil + + -- Set metadata authors for this thread + thread_metadata.authors = vim.tbl_keys(thread_metadata._authors_seen) + thread_metadata._authors_seen = nil + + return lines, thread_metadata end return T From b570007132ff05c14fc78f45c3d3352e4ba2db0d Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Sun, 18 Jan 2026 11:01:53 +0700 Subject: [PATCH 09/23] feat(thread): add per-message metadata buffer variable Add `vim.b.notmuch_messages` buffer variable containing an array of message objects for cursor-to-message lookup and statusline integration. Each message entry includes: - id: Message-ID for notmuch queries - start_line, end_line, fold_line: Line ranges for cursor mapping - depth: Reply depth in thread - from, subject, date_relative: Display fields - tags: Per-message tags array - attachment_count: Number of attachments Implementation: - Track line positions during `build_message_lines()` traversal - Restructure metadata return as { thread, messages } object - Caller sets both `vim.b.notmuch_thread` and `vim.b.notmuch_messages` This enables O(m) message lookup by cursor position, replacing the O(n) buffer-scanning approach in `util.find_cursor_msg_id()`. --- lua/notmuch/init.lua | 3 +- lua/notmuch/thread.lua | 86 +++++++++++++++++++++++++++++------------- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index 8da66e6..9aaeda7 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -175,7 +175,8 @@ nm.show_thread = function(s) local lines, metadata = require('notmuch.thread').show_thread(threadid) v.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.b.notmuch_thread = metadata + vim.b.notmuch_thread = metadata.thread + vim.b.notmuch_messages = metadata.messages -- Insert hint message at the top of the buffer local hint_text = diff --git a/lua/notmuch/thread.lua b/lua/notmuch/thread.lua index 649437f..5ef5124 100644 --- a/lua/notmuch/thread.lua +++ b/lua/notmuch/thread.lua @@ -30,7 +30,7 @@ --- integration and so on): --- --- `vim.b.notmuch_thread` : Current thread metadata ---- `vim.b.notmuch_thread_messages` : Array of message objects inside thread +--- `vim.b.notmuch_messages` : Array of message objects inside thread --- `vim.b.notmuch_current` : Cursor-tracked current message data --- `vim.b.notmuch_status` : Formatted string for quick statusline @@ -87,13 +87,18 @@ local function format_headers(msg, depth) local lines = {} local headers = msg.headers or {} + -- Helper to remove folded lines (RFC 2822) + local function unfold(s) + return s and s:gsub('\r?\n%S*', ' ') or "" + end + -- Extract header values with fallbacks - local from = headers.From or "[Unknown sender]" - local subject = headers.Subject or "[No subject]" - local to = headers.To or "" - local cc = headers.Cc - local date_full = headers.Date or "" - local date_relative = msg.date_relative or "" + local from = unfold(headers.From) or "[Unknown sender]" + local subject = unfold(headers.Subject) or "[No subject]" + local to = unfold(headers.To) or "" + local cc = unfold(headers.Cc) + local date_full = unfold(headers.Date) or "" + local date_relative = unfold(msg.date_relative) or "" -- Get tags and attachment info local tags = msg.tags or {} @@ -226,24 +231,33 @@ local function build_message_lines(msg_node, depth, lines, metadata) local msg = msg_node[1] local replies = msg_node[2] or {} + -- Keep an offset of the header at the top of the buffer (hints + blank line) + local HEADER_OFFSET = 2 + -- Update thread metadata with this message metadata - metadata.message_count = metadata.message_count + 1 + metadata.thread.message_count = metadata.thread.message_count + 1 + + -- Track the message's starting line number (where summary is shown) + local start_line = #lines + 1 + HEADER_OFFSET -- Update tags seen in the thread for _, tag in ipairs(msg.tags) do - metadata._tags_set[tag] = true + metadata.thread._tags_set[tag] = true end -- Add author into metadata list local from = (msg.headers or {}).From if from and from ~= "" then - metadata._authors_seen[from] = true + metadata.thread._authors_seen[from] = true end -- Parse and prepare header lines local headers = format_headers(msg, depth) vim.list_extend(lines, headers) + -- Track message fold starting line (line with '{{{' fold opening marker) + local fold_line = start_line + 1 + -- Extract body and content local body = process_body_parts(msg.body) vim.list_extend(lines, body) @@ -251,9 +265,26 @@ local function build_message_lines(msg_node, depth, lines, metadata) -- Add fold end marker table.insert(lines, "}}}") + -- Track message's last line (line with fold closing marker '}}}' + local end_line = #lines + HEADER_OFFSET + -- Add blank line separator after message table.insert(lines, "") + -- Add message entry into the metadata list of messages + table.insert(metadata.messages, { + id = msg.id, + start_line = start_line, + fold_line = fold_line, + end_line = end_line, + depth = depth, + from = (msg.headers or {}).From or "", + date_relative = msg.date_relative or "", + subject = (msg.headers or {}).Subject or "", + tags = msg.tags or {}, + attachment_count = select(2, has_attachments(msg.body)), + }) + -- Process replies recursively if replies and #replies > 0 then for _, reply_node in ipairs(replies) do @@ -323,31 +354,34 @@ T.show_thread = function(threadid) local root_msg = root_node[1] -- Initialize `vim.b.notmuch_thread` accumulator - local thread_metadata = { - id = threadid, - subject = (root_msg.headers or {}).Subject or "[No subject]", - date_relative = root_msg.date_relative or "", - message_count = 0, - tags = {}, - _tags_set = {}, -- temporary: set for deduplication - authors = {}, - _authors_seen = {}, -- temporary: set for deduplication + local metadata = { + thread = { + id = threadid, + subject = (root_msg.headers or {}).Subject or "[No subject]", + date_relative = root_msg.date_relative or "", + message_count = 0, + tags = {}, + _tags_set = {}, -- temporary: set for deduplication + authors = {}, + _authors_seen = {}, -- temporary: set for deduplication + }, + messages = {}, } -- Build buffer lines (also builds accumulated thread metadata) local lines = {} - build_message_lines(root_node, 0, lines, thread_metadata) + build_message_lines(root_node, 0, lines, metadata) -- Set metadata tags based on ordered list of seen tags during recursion - thread_metadata.tags = vim.tbl_keys(thread_metadata._tags_set) - table.sort(thread_metadata.tags) - thread_metadata._tags_set = nil + metadata.thread.tags = vim.tbl_keys(metadata.thread._tags_set) + table.sort(metadata.thread.tags) + metadata.thread._tags_set = nil -- Set metadata authors for this thread - thread_metadata.authors = vim.tbl_keys(thread_metadata._authors_seen) - thread_metadata._authors_seen = nil + metadata.thread.authors = vim.tbl_keys(metadata.thread._authors_seen) + metadata.thread._authors_seen = nil - return lines, thread_metadata + return lines, metadata end return T From 91c9379f5c754aa6d5ca7b33d91ca157933923c3 Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Sun, 25 Jan 2026 02:38:39 +0700 Subject: [PATCH 10/23] feat(thread): add cursor-tracked current message buffer variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `vim.b.notmuch_current` and `vim.b.notmuch_status` buffer variables that automatically update as the cursor moves through the thread. `vim.b.notmuch_current` contains: - All fields from the message at cursor (id, from, tags, etc.) - index: 1-based position in thread - total: total message count `vim.b.notmuch_status` contains a pre-formatted string for statusline: - Format: "2/7 Alice Smith" or "2/7 Alice Smith 📎2" with attachments Implementation: - Add get_message_at_line() for O(m) cursor-to-message lookup - Add update_current_message() to refresh buffer variables - Add setup_cursor_tracking() to create buffer-local CursorMoved autocmd - Fast path optimization skips update if cursor still in same message --- lua/notmuch/init.lua | 6 ++++ lua/notmuch/thread.lua | 67 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index 9aaeda7..f3d9c74 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -173,8 +173,11 @@ nm.show_thread = function(s) v.nvim_buf_set_name(buf, "thread:" .. threadid) v.nvim_win_set_buf(0, buf) + -- Get output (JSON parsed) and display lines in buffer local lines, metadata = require('notmuch.thread').show_thread(threadid) v.nvim_buf_set_lines(buf, 0, -1, false, lines) + + -- Set up buffer-local variables with thread metadata vim.b.notmuch_thread = metadata.thread vim.b.notmuch_messages = metadata.messages @@ -188,6 +191,9 @@ nm.show_thread = function(s) v.nvim_win_set_cursor(0, { 1, 0}) vim.bo.filetype="mail" vim.bo.modifiable = false + + -- Set up cursor tracking for updating vim.b.notmuch_current + require('notmuch.thread').setup_cursor_tracking(buf) end -- Counts the number of threads matching the search terms diff --git a/lua/notmuch/thread.lua b/lua/notmuch/thread.lua index 5ef5124..03a971c 100644 --- a/lua/notmuch/thread.lua +++ b/lua/notmuch/thread.lua @@ -297,12 +297,79 @@ end -- PRIVATE: Buffer variable builders -------------------------------------------------------------------------------- +--- Get message in the thread from the given line number +--- @param line? number Line number in the buffer. 0 refers to cursor line) +--- @return table|nil message Message object or nil if none is found +--- @return number|nil index 1-based index of the message in the thread or nil +local function get_message_at_line(line) + line = line or vim.api.nvim_win_get_cursor(0)[1] + local messages = vim.b.notmuch_messages + if not messages then return nil, nil end + + -- Find corresponding message (`line` is within message start/end bounds) + for i, msg in ipairs(messages) do + if line >= msg.start_line and line <= msg.end_line then + return msg, i + end + end + -- Not found + return nil, nil +end + +--- Update current message tracker variable based on cursor line position +--- Updates vim.b.notmuch_current and vim.b.notmuch_status +local function update_current_message() + local line = vim.api.nvim_win_get_cursor(0)[1] + local current = vim.b.notmuch_current + + -- Fast check: cursor is still in same message + if current and line >= current.start_line and line <= current.end_line then + return + end + + -- Find message at cursor + local msg, index = get_message_at_line(line) + if not msg then + vim.b.notmuch_current = nil + vim.b.notmuch_status = "" + return + end + + -- Update current message buffer variable + local total = #vim.b.notmuch_messages + vim.b.notmuch_current = vim.tbl_extend("force", msg, { + index = index, + total = total, + }) + + -- Format status string + local from_name = msg.from:match("^([^<]+)") or msg.from + from_name = vim.trim(from_name) + local status = string.format("%d/%d %s", index, total, from_name) + if msg.attachment_count > 0 then + status = status .. " 📎" .. msg.attachment_count + end + vim.b.notmuch_status = status +end -------------------------------------------------------------------------------- -- PUBLIC: Show thread main entry point -------------------------------------------------------------------------------- +--- Set up autocmd for updating current message tracker on CursorMove +--- @param bufnr number Buffer number +function T.setup_cursor_tracking(bufnr) + -- Updates the current message in buffer local variable with each CursorMoved + vim.api.nvim_create_autocmd('CursorMoved', { + buffer = bufnr, + callback = update_current_message, + }) + + -- Initial current message variable + update_current_message() +end + --- Fetches and renders a thread as buffer lines --- --- Runs `notmuch show --format=json` to fetch the thread, parses the JSON From 1fee2631ed5776226365379bc6f5220a3f68c849 Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Sun, 25 Jan 2026 03:20:41 +0700 Subject: [PATCH 11/23] refactor: replace `util.find_cursor_msg_id()` with buffer-local access Simplifies operations (8 instances) where ID was being fetching the current (cursor) message ID by scanning lines backwards. Instead, we simply reference the `id` field in the buffer-local variable `vim.b.notmuch_current`, which is updated via autocmd on every cursor move event. --- lua/notmuch/attach.lua | 5 +++-- lua/notmuch/send.lua | 3 ++- lua/notmuch/tag.lua | 7 ++++--- lua/notmuch/thread.lua | 17 +++++++++++++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lua/notmuch/attach.lua b/lua/notmuch/attach.lua index 030052e..6edf2c4 100644 --- a/lua/notmuch/attach.lua +++ b/lua/notmuch/attach.lua @@ -3,6 +3,7 @@ local u = require('notmuch.util') local v = vim.api local config = require('notmuch.config') +local thread = require('notmuch.thread') local function show_github_patch(link) local buf = v.nvim_create_buf(true, true) @@ -334,7 +335,7 @@ end --- (openable parts) are displayed with their notmuch part IDs for direct access. a.get_attachments_from_cursor_msg = function() -- Get msg ID from cursor location and validate - local id = u.find_cursor_msg_id() + local id = thread.get_current_message_id() if id == nil then return nil end -- If attachment buffer already exists, notify and return @@ -371,7 +372,7 @@ a.get_urls_from_cursor_msg = function() print("Can't launch URL selector (:YTerm command not found)") return nil end - local id = u.find_cursor_msg_id() + local id = thread.get_current_message_id() if id == nil then return nil end v.nvim_command('YTerm "notmuch show id:' .. id .. ' | urlextract"') end diff --git a/lua/notmuch/send.lua b/lua/notmuch/send.lua index c788f9c..8e6a941 100644 --- a/lua/notmuch/send.lua +++ b/lua/notmuch/send.lua @@ -1,6 +1,7 @@ local s = {} local u = require('notmuch.util') local m = require('notmuch.mime') +local thread = require('notmuch.thread') local v = vim.api local config = require('notmuch.config') @@ -241,7 +242,7 @@ end -- require('notmuch.send').reply() s.reply = function() -- Get msg id of the mail to be replied to - local id = u.find_cursor_msg_id() + local id = thread.get_current_message_id() if not id then return end -- Create new draft mail to hold reply diff --git a/lua/notmuch/tag.lua b/lua/notmuch/tag.lua index 565a6b9..465eed8 100644 --- a/lua/notmuch/tag.lua +++ b/lua/notmuch/tag.lua @@ -1,5 +1,6 @@ local t = {} local v = vim.api +local thread = require('notmuch.thread') local u = require'notmuch.util' local config = require('notmuch.config') @@ -7,7 +8,7 @@ local config = require('notmuch.config') t.msg_add_tag = function(tags) local t = u.split(tags, '%S+') local db = require'notmuch.cnotmuch'(config.options.notmuch_db_path, 1) - local id = u.find_cursor_msg_id() + local id = thread.get_current_message_id() if id == nil then return end local msg = db.get_message(id) for i,tag in pairs(t) do @@ -20,7 +21,7 @@ end t.msg_rm_tag = function(tags) local t = u.split(tags, '%S+') local db = require'notmuch.cnotmuch'(config.options.notmuch_db_path, 1) - local id = u.find_cursor_msg_id() + local id = thread.get_current_message_id() if id == nil then return end local msg = db.get_message(id) for i,tag in pairs(t) do @@ -33,7 +34,7 @@ end t.msg_toggle_tag = function(tags) local t = u.split(tags, '%S+') local db = require'notmuch.cnotmuch'(config.options.notmuch_db_path, 1) - local id = u.find_cursor_msg_id() + local id = thread.get_current_message_id() if id == nil then return end local msg = db.get_message(id) local curr_tags = msg:get_tags() diff --git a/lua/notmuch/thread.lua b/lua/notmuch/thread.lua index 03a971c..bb3b7b8 100644 --- a/lua/notmuch/thread.lua +++ b/lua/notmuch/thread.lua @@ -353,6 +353,23 @@ local function update_current_message() vim.b.notmuch_status = status end +-------------------------------------------------------------------------------- +-- PUBLIC: Buffer-local variables interface +-------------------------------------------------------------------------------- + +--- Returns current message object from buffer local variable +--- @return table|nil message Current message object with all fields +function T.get_current_message() + return vim.b.notmuch_current +end + +--- Returns current message ID from buffer local variable +--- @return string|nil id Message ID of the current message or nil +function T.get_current_message_id() + local current = vim.b.notmuch_current + return current and current.id +end + -------------------------------------------------------------------------------- -- PUBLIC: Show thread main entry point -------------------------------------------------------------------------------- From 8db16c0be2d112d59bd1dd884f514294e94d1734 Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Tue, 27 Jan 2026 03:55:52 +0700 Subject: [PATCH 12/23] docs(changelog): document buffer-local variables and message lookup improvements Adds detail on new thread metadata buffer variables for statusline integration and clarifies the refactored message ID lookup pattern. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc81a8..33c7dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Per-message tag display in thread view header - Attachment count indicator (📎) in message headers - MIME part markers in message body for attachments and HTML content +- Buffer-local variables for thread metadata (enables statusline integration and user extensibility) + - `vim.b.notmuch_thread` - Thread-level metadata (ID, subject, tags, authors, message count) + - `vim.b.notmuch_messages` - Array of all messages in thread with metadata + - `vim.b.notmuch_current` - Cursor-tracked current message data with position info + - `vim.b.notmuch_status` - Formatted statusline string showing message position and sender - FFI bindings for `notmuch_database_open_with_config` (notmuch 5.4+/API 0.32+) - Automatic notmuch version detection at module load with result caching - Warning notification when using deprecated API (notmuch < 0.32) @@ -30,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Proper handling of complex MIME structures (multipart/mixed, multipart/alternative) - Concatenates multiple inline text parts (body, signatures, mailing list footers) - Now fetches HTML content (`--include-html`) for optional rendering +- Message ID lookup now uses buffer-local variables instead of regex parsing + - Replaces `util.find_cursor_msg_id()` with `thread.get_current_message_id()` + - Cursor position automatically tracked via `CursorMoved` autocmd + - Eliminates redundant buffer scans for message operations - Database opening now uses `notmuch_database_open_with_config` when available, with fallback to deprecated `notmuch_database_open` - Version detection runs once at module load for improved performance From ecd72fffa9e22982f0d88ecef5a839889f83889a Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Wed, 28 Jan 2026 18:26:09 +0700 Subject: [PATCH 13/23] fix(thread): process all root-level nodes in thread display Previously, show_thread() only processed json[1][1], assuming a single root node. Threads with multiple depth-0 messages (e.g., orphaned replies, merged threads) were truncated to only the first message. Now iterates over all nodes in json[1] to display the complete thread. --- lua/notmuch/thread.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lua/notmuch/thread.lua b/lua/notmuch/thread.lua index bb3b7b8..d98629d 100644 --- a/lua/notmuch/thread.lua +++ b/lua/notmuch/thread.lua @@ -434,8 +434,8 @@ T.show_thread = function(threadid) return { "Thread data is malformed or empty" }, {} end - local root_node = json[1][1] - local root_msg = root_node[1] + local thread = json[1] + local root_msg = thread[1][1] -- Initialize `vim.b.notmuch_thread` accumulator local metadata = { @@ -454,7 +454,9 @@ T.show_thread = function(threadid) -- Build buffer lines (also builds accumulated thread metadata) local lines = {} - build_message_lines(root_node, 0, lines, metadata) + for _, node in ipairs(thread) do + build_message_lines(node, 0, lines, metadata) + end -- Set metadata tags based on ordered list of seen tags during recursion metadata.thread.tags = vim.tbl_keys(metadata.thread._tags_set) From d6b1bd2c3508296baa4e2e843a32e0b5f46fc8fc Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Sun, 1 Feb 2026 11:49:23 +0700 Subject: [PATCH 14/23] fix(thread): find next message when cursor is between boundaries When cursor is at the top of the buffer or in a gap between messages, update_current_message() now falls back to finding the first message whose start_line is after the cursor position. Previously it would clear notmuch_current and notmuch_status, leaving the status bar empty despite messages being present in the thread. --- lua/notmuch/thread.lua | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lua/notmuch/thread.lua b/lua/notmuch/thread.lua index d98629d..1e90362 100644 --- a/lua/notmuch/thread.lua +++ b/lua/notmuch/thread.lua @@ -319,6 +319,8 @@ end --- Update current message tracker variable based on cursor line position --- Updates vim.b.notmuch_current and vim.b.notmuch_status +--- If cursor is out of bounds (at the top or between messages) it will display +--- the *next* message as the current message local function update_current_message() local line = vim.api.nvim_win_get_cursor(0)[1] local current = vim.b.notmuch_current @@ -330,6 +332,21 @@ local function update_current_message() -- Find message at cursor local msg, index = get_message_at_line(line) + + -- If cursor is not in a message, find next message after cursor + if not msg then + local messages = vim.b.notmuch_messages + if messages then + for i, m in ipairs(messages) do + if m.start_line > line then + msg, index = m, i + break + end + end + end + end + + -- Still no message found (shouldn't happen but need nil check) if not msg then vim.b.notmuch_current = nil vim.b.notmuch_status = "" From 078e2616ced1596919d04e691d04eb7732cd4774 Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Wed, 4 Feb 2026 13:27:33 +0700 Subject: [PATCH 15/23] docs: add buffer-local variables and render_html_body documentation - README: Add w3m as optional dependency, render_html_body config option, and new "Statusline Integration" section with lualine example - Help: Add render_html_body option, w3m dependency, comprehensive buffer-local variables reference (:help notmuch-buffer-variables), and thread.lua module description - CHANGELOG: Note documentation additions --- CHANGELOG.md | 3 ++ README.md | 31 ++++++++++++++ doc/notmuch.txt | 105 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c7dab..0ddc13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation - Installation instructions for Neovim v0.12+ builtin package manager (`vim.pack.add`) +- Buffer-local variables reference in README and help docs (`:help notmuch-buffer-variables`) +- `render_html_body` configuration option with w3m dependency note +- `w3m` listed as optional dependency for HTML rendering ## [0.2.0] - 2026-01-09 diff --git a/README.md b/README.md index 088ec7c..eb80020 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ the familiar Vim interface and motions. required due to LuaJIT support. - **[Notmuch](https://notmuchmail.org)**: Ensure Notmuch and libnotmuch library are installed +- **[w3m](http://w3m.sourceforge.net/)** (optional): Required for inline HTML + email rendering when `render_html_body = true` - (WIP) ~~**[Telescope.nvim](https://github.com/nvim-telescope/telescope.nvim)**: File picker of choice for many use cases.~~ @@ -131,6 +133,7 @@ You can configure several global options to tailor the plugin's behavior: | `keymaps` | Configure any (WIP) command's keymap | See `config.lua`[1] | | `open_handler` | Callback function for opening attachments | Runs OS-aware `open`[2] | | `view_handler` | Callback function for converting attachments to text to view in floating window | See `default_view_handler()`[2] | +| `render_html_body` | Render HTML email bodies inline using `w3m` (requires `w3m` installed) | `false` | [1]: https://github.com/yousefakbar/notmuch.nvim/blob/main/lua/notmuch/config.lua [2]: https://github.com/yousefakbar/notmuch.nvim/blob/main/lua/notmuch/handlers.lua @@ -149,6 +152,7 @@ Example configuration in plugin manager (lazy.nvim): keymaps = { sendmail = "", }, + render_html_body = true, -- Render HTML emails inline (requires w3m) }, }, ``` @@ -191,6 +195,33 @@ require('notmuch').setup({ The default handlers are defined in `lua/notmuch/handlers.lua` and handle many common formats out of the box. Only override them if you need specific behavior. +### Statusline Integration + +When viewing a thread, the plugin exposes buffer-local variables that can be +used for statusline integration or other extensibility purposes: + +| Variable | Description | +| :------- | :---------- | +| `vim.b.notmuch_thread` | Thread metadata (ID, subject, tags, authors, message count) | +| `vim.b.notmuch_messages` | Array of all messages with line positions and metadata | +| `vim.b.notmuch_current` | Cursor-tracked current message (updates on `CursorMoved`) | +| `vim.b.notmuch_status` | Pre-formatted statusline string (e.g., "2/5 John Doe 📎1") | + +Example statusline integration with lualine: + +```lua +require('lualine').setup({ + sections = { + lualine_c = { + { + function() return vim.b.notmuch_status or '' end, + cond = function() return vim.bo.filetype == 'mail' end, + }, + }, + }, +}) +``` + ## License This project is licensed under the MIT License, granting you the freedom to use, diff --git a/doc/notmuch.txt b/doc/notmuch.txt index bc90d5f..712826a 100644 --- a/doc/notmuch.txt +++ b/doc/notmuch.txt @@ -16,6 +16,7 @@ CONTENTS *notmuch-contents* Completion |notmuch-completion| Behavior |notmuch-behavior| Buffers |notmuch-buffers| + Buffer-Local Variables |notmuch-buffer-variables| Asynchronous Operations |notmuch-async| Highlighting |notmuch-syntax-highlight| Lua Structure |notmuch-structure| @@ -120,6 +121,15 @@ Notmuch~ `Notmuch`: https://notmuchmail.org +w3m (Optional)~ + + The w3m text-based browser is used for rendering HTML email bodies inline + when the |render_html_body| option is enabled. Without w3m installed, HTML + emails will display a placeholder with instructions to view via the + attachment interface. + + `w3m`: http://w3m.sourceforge.net/ + ------------------------------------------------------------------------------ INSTALLATION *notmuch-installation* @@ -319,6 +329,29 @@ option will be listed with its default value. Default value: See `config.lua` for plugin defaults. +*render_html_body* + Controls whether HTML email bodies are rendered inline in thread view using + `w3m`. When enabled, the plugin will convert HTML content to plain text for + display in the buffer. This is useful for emails that are sent as HTML-only + or where the HTML version contains better formatting than the text/plain + alternative. + + When disabled (the default), HTML parts are hidden with a marker indicating + they can be viewed via the attachment interface (press 'a'). + + Note: Requires `w3m` to be installed. If w3m is not available, a fallback + message is displayed instead. + + Default value: + `false` + + Example: > + + require('notmuch').setup({ + render_html_body = true, + }) +< + ------------------------------------------------------------------------------ COMPLETION *notmuch-completion* @@ -401,6 +434,68 @@ These features leverage NeoVim's buffer system to deliver a seamless and efficient email management experience, making it easier to navigate and interact with your email directly from the editor. +- *Buffer-Local Variables* ~ + *notmuch-buffer-variables* + Thread View buffers expose several buffer-local variables containing + metadata about the current thread and messages. These can be used for + statusline integration, custom keymaps, or other extensibility purposes. + + *vim.b.notmuch_thread* + Table containing thread-level metadata: + - `id`: Thread ID (e.g., "000000000000abcd") + - `subject`: Subject line from the root message + - `tags`: Array of all unique tags across messages in the thread + - `authors`: Array of all unique authors (From addresses) + - `message_count`: Total number of messages in the thread + - `date_relative`: Relative date string (e.g., "2 hours ago") + + *vim.b.notmuch_messages* + Array of message objects, one per message in the thread. Each contains: + - `id`: Message ID + - `start_line`: First line of this message in buffer + - `end_line`: Last line of this message in buffer + - `fold_line`: Line containing the fold marker + - `depth`: Reply depth (0 for root message) + - `from`: From header value + - `subject`: Subject header value + - `date_relative`: Relative date string + - `tags`: Array of tags on this specific message + - `attachment_count`: Number of attachments + + *vim.b.notmuch_current* + Table containing the message under the cursor. Updated automatically via + |CursorMoved| autocmd. Contains all fields from notmuch_messages plus: + - `index`: 1-based position in the thread (e.g., 2 of 5) + - `total`: Total message count + + *vim.b.notmuch_status* + Pre-formatted string suitable for statusline display. Format: + `{index}/{total} {sender_name} [📎{attachment_count}]` + Example: "2/5 John Doe 📎1" + + Example statusline integration with lualine: > + + require('lualine').setup({ + sections = { + lualine_c = { + { + function() return vim.b.notmuch_status or '' end, + cond = function() return vim.bo.filetype == 'mail' end, + }, + }, + }, + }) +< + Example custom keymap using buffer variables: > + + vim.keymap.set('n', 'mt', function() + local thread = vim.b.notmuch_thread + if thread then + print("Thread: " .. thread.subject) + print("Tags: " .. table.concat(thread.tags, ", ")) + end + end) +< ------------------------------------------------------------------------------ ASYNCHRONOUS OPERATIONS *notmuch-async* @@ -508,6 +603,16 @@ plugin's project codebase. - Defines the default configuration options. - Implements configuration option overrides set by the user. + - **thread.lua**: + - Handles JSON-based thread display and parsing. + - Runs `notmuch show --format=json` and transforms output into buffer + lines with fold markers, formatted headers, and rendered body content. + - Walks MIME trees for multipart messages with optional HTML rendering. + - Exports thread metadata to buffer-local variables (see + |notmuch-buffer-variables|). + - Sets up cursor tracking to update |vim.b.notmuch_current| on + |CursorMoved|. + - **tag.lua**: - Handles operations related to tagging threads and messages. - Implements functions to add, remove, or toggle tags on individual From 09ea44af102a461cb1d111369c769c10368fda55 Mon Sep 17 00:00:00 2001 From: Antoine Saez Dumas Date: Wed, 21 Jan 2026 14:21:54 +0100 Subject: [PATCH 16/23] refactor: port completion functions from vimscript to lua --- autoload/notmuch.vim | 71 --------------- lua/notmuch/cmdline_completions.lua | 131 ++++++++++++++++++++++++++++ lua/notmuch/init.lua | 33 +++++-- plugin/notmuch.vim | 12 --- 4 files changed, 159 insertions(+), 88 deletions(-) delete mode 100644 autoload/notmuch.vim create mode 100644 lua/notmuch/cmdline_completions.lua delete mode 100644 plugin/notmuch.vim diff --git a/autoload/notmuch.vim b/autoload/notmuch.vim deleted file mode 100644 index 1203d82..0000000 --- a/autoload/notmuch.vim +++ /dev/null @@ -1,71 +0,0 @@ -let s:search_terms_list = [ "attachment:", "folder:", "id:", "mimetype:", - \ "property:", "subject:", "thread:", "date:", "from:", "lastmod:", - \ "path:", "query:", "tag:", "is:", "to:", "body:", "and ", "or ", "not " ] - -function! notmuch#CompSearchTerms(ArgLead, CmdLine, CursorPos) abort - if match(a:ArgLead, "tag:") != -1 - let l:tag_list = split(system('notmuch search --output=tags "*"'), '\n') - return "tag:" .. join(l:tag_list, "\ntag:") - endif - if match(a:ArgLead, "is:") != -1 - let l:is_list = split(system('notmuch search --output=tags "*"'), '\n') - return "is:" .. join(l:is_list, "\nis:") - endif - if match(a:ArgLead, "mimetype:") != -1 - let l:mimetype_list = ["application/", "audio/", "chemical/", - \ "font/", "image/", "inode/", "message/", "model/", - \ "multipart/", "text/", "video/"] - return "mimetype:" .. join(l:mimetype_list, "\nmimetype:") - endif - if match(a:ArgLead, "from:") != -1 - let l:from_list = split(system('notmuch address "*"'), '\n') - return "from:" .. join(l:from_list, "\nfrom:") - endif - if match(a:ArgLead, "to:") != -1 - let l:to_list = split(system('notmuch address "*"'), '\n') - return "to:" .. join(l:to_list, "\nto:") - endif - if match(a:ArgLead, "folder:") != -1 - let l:cur_dirs = split(system('find ' .. shellescape(g:notmuch_mailroot) .. ' -type d -name cur'), '\n') - let folder_list = [] - let l:mailroot_pattern = '^' .. escape(g:notmuch_mailroot, '/.\\$*[]^') .. '/\?' - for dir in l:cur_dirs - let l:parent = fnamemodify(dir, ':h') - let l:relative = substitute(l:parent, l:mailroot_pattern, '', '') - if !empty(l:relative) - " Quote folder names that contain spaces or special characters - if match(l:relative, '[ \[\]]') != -1 - let l:relative = '"' .. l:relative .. '"' - endif - call add(l:folder_list, l:relative) - endif - endfor - return "folder:" .. join(uniq(sort(l:folder_list)), "\nfolder:") - endif - if match(a:ArgLead, "path:") != -1 - let l:all_dirs = split(system('find ' .. shellescape(g:notmuch_mailroot) .. ' -type d'), '\n') - let l:path_list = [] - let l:mailroot_pattern = '^' .. escape(g:notmuch_mailroot, '/.\\$*[]^') .. '/\?' - for dir in l:all_dirs - let l:relative = substitute(dir, l:mailroot_pattern, '', '') - if !empty(l:relative) - " Quote paths that contain spaces or special characters - if match(l:relative, '[ \[\]]') != -1 - let l:relative = '"' .. l:relative .. '"' - endif - call add(l:path_list, l:relative) - endif - endfor - return "path:" .. join(uniq(sort(l:path_list)), "\npath:") - endif - return join(s:search_terms_list, "\n") -endfunction - -function! notmuch#CompTags(ArgLead, CmdLine, CursorPos) abort - return system('notmuch search --output=tags "*"') -endfunction - -function! notmuch#CompAddress(ArgLead, CmdLine, CursorPos) abort - return system('notmuch address "*"') -endfunction -" vim: tabstop=2:shiftwidth=2:expandtab diff --git a/lua/notmuch/cmdline_completions.lua b/lua/notmuch/cmdline_completions.lua new file mode 100644 index 0000000..77d4090 --- /dev/null +++ b/lua/notmuch/cmdline_completions.lua @@ -0,0 +1,131 @@ +local cmdline_cmp = {} + +local search_terms_list = { + "attachment:", "folder:", "id:", "mimetype:", + "property:", "subject:", "thread:", "date:", "from:", "lastmod:", + "path:", "query:", "tag:", "is:", "to:", "body:", "and ", "or ", "not ", +} + +local function starts_with(str, prefix) + return str:find(prefix, 1, true) ~= nil +end + +local function quote_if_needed(s) + if s:find("[ %[%]]") then + return '"' .. s .. '"' + end + return s +end + +local function uniq_sorted(list) + table.sort(list) + local res = {} + local prev + for _, v in ipairs(list) do + if v ~= prev then + res[#res + 1] = v + prev = v + end + end + return res +end + +-- returns the notmuch database root, or nil in case of error +---@return string|nil +local function get_mailroot() + if vim.g.notmuch_mailroot then + return vim.g.notmuch_mailroot + end + + local nm_get_config = vim.system({ "notmuch", "config", "get", "database.mail_root" }):wait() + if nm_get_config.code ~= 0 or #nm_get_config.stdout == 0 then + vim.notify( + "notmuch.nvim: Failed to get database.mail_root from notmuch config", + vim.log.levels.ERROR + ) + return + end + return vim.trim(nm_get_config.stdout) +end + +function cmdline_cmp.comp_search_terms(arglead, _, _) + if starts_with(arglead, "tag:") then + local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) + return vim.tbl_map(function(t) + return "tag:" .. t + end, tags) + elseif starts_with(arglead, "is:") then + local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) + return vim.tbl_map(function(t) + return "is:" .. t + end, tags) + elseif starts_with(arglead, "mimetype:") then + local mimetypes = { + "application/", "audio/", "chemical/", "font/", "image/", + "inode/", "message/", "model/", "multipart/", "text/", "video/", + } + return vim.tbl_map(function(m) + return "mimetype:" .. m + end, mimetypes) + elseif starts_with(arglead, "from:") then + local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) + return vim.tbl_map(function(a) + return "from:" .. a + end, addrs) + elseif starts_with(arglead, "to:") then + local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) + return vim.tbl_map(function(a) + return "to:" .. a + end, addrs) + elseif starts_with(arglead, "folder:") then + local mailroot = get_mailroot() + if not mailroot then return {} end + + local dirs = vim.fn.systemlist({ + "find", mailroot, "-type", "d", "-name", "cur", + }) + + local folders = {} + local pattern = "^" .. vim.pesc(mailroot) .. "/?" + + for _, dir in ipairs(dirs) do + local parent = vim.fn.fnamemodify(dir, ":h") + local rel = parent:gsub(pattern, "") + if rel ~= "" then + folders[#folders + 1] = "folder:" .. quote_if_needed(rel) + end + end + + return uniq_sorted(folders) + elseif starts_with(arglead, "path:") then + local mailroot = get_mailroot() + if not mailroot then return {} end + + local dirs = vim.fn.systemlist({ "find", mailroot, "-type", "d" }) + + local paths = {} + local pattern = "^" .. vim.pesc(mailroot) .. "/?" + + for _, dir in ipairs(dirs) do + local rel = dir:gsub(pattern, "") + if rel ~= "" then + paths[#paths + 1] = "path:" .. quote_if_needed(rel) + end + end + + return uniq_sorted(paths) + end + + -- default: search terms + return search_terms_list +end + +function cmdline_cmp.comp_tags(_, _, _) + return vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) +end + +function cmdline_cmp.comp_address(_, _, _) + return vim.fn.systemlist({ "notmuch", "address", "*" }) +end + +return cmdline_cmp diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index f3d9c74..7a3f497 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -28,15 +28,38 @@ nm.setup = function(opts) return end - -- Set up the main entry point command :Notmuch - vim.cmd [[command Notmuch :lua require('notmuch').notmuch_hello()]] + -- setup user commands + vim.api.nvim_create_user_command("Notmuch", + nm.notmuch_hello, + { + desc = "notmuch.nvim landing page", + } + ) vim.api.nvim_create_user_command("Inbox", function(arg) if #arg.fargs ~= 0 then - nm.search_terms("tag:inbox to:" .. arg.args) + require("notmuch").search_terms("tag:inbox to:" .. arg.args) else - nm.search_terms("tag:inbox") + require("notmuch").search_terms("tag:inbox") end - end, { desc = "Open inbox", nargs = "?", complete = "custom,notmuch#CompAddress" }) + end, { + desc = "Open inbox", + nargs = "?", + complete = require("notmuch.cmdline_completions").comp_address + }) + vim.api.nvim_create_user_command("NmSearch", function(arg) + nm.search_terms(arg.args) + end, { + desc = "Notmuch search", + nargs = "*", + complete = require("notmuch.cmdline_completions").comp_search_terms + }) + vim.api.nvim_create_user_command("ComposeMail", function(arg) + require("notmuch.send").compose(arg.args) + end, { + desc = "Compose mail", + nargs = "*", + complete = require("notmuch.cmdline_completions").comp_address + }) end -- Launch `notmuch.nvim` landing page diff --git a/plugin/notmuch.vim b/plugin/notmuch.vim deleted file mode 100644 index 57dc341..0000000 --- a/plugin/notmuch.vim +++ /dev/null @@ -1,12 +0,0 @@ -let g:notmuch_mailroot = trim(system('notmuch config get database.mail_root')) -if v:shell_error != 0 || empty(g:notmuch_mailroot) - echohl ErrorMsg - echom 'notmuch.nvim: Failed to get database.mail_root from notmuch config' - echohl None - finish -endif - -command -complete=custom,notmuch#CompSearchTerms -nargs=* NmSearch :call v:lua.require('notmuch').search_terms() -command -complete=custom,notmuch#CompAddress -nargs=* ComposeMail :call v:lua.require('notmuch.send').compose() - -" vim: tabstop=2:shiftwidth=2:expandtab From a1da87f73cf36387ee890ed1b07550b8547348c3 Mon Sep 17 00:00:00 2001 From: Antoine Saez Dumas Date: Fri, 6 Feb 2026 19:23:21 +0100 Subject: [PATCH 17/23] fix(cmdline_completions): filter search results --- lua/notmuch/cmdline_completions.lua | 149 +++++++++++++++------------- 1 file changed, 79 insertions(+), 70 deletions(-) diff --git a/lua/notmuch/cmdline_completions.lua b/lua/notmuch/cmdline_completions.lua index 77d4090..feb9b1d 100644 --- a/lua/notmuch/cmdline_completions.lua +++ b/lua/notmuch/cmdline_completions.lua @@ -6,10 +6,6 @@ local search_terms_list = { "path:", "query:", "tag:", "is:", "to:", "body:", "and ", "or ", "not ", } -local function starts_with(str, prefix) - return str:find(prefix, 1, true) ~= nil -end - local function quote_if_needed(s) if s:find("[ %[%]]") then return '"' .. s .. '"' @@ -30,11 +26,19 @@ local function uniq_sorted(list) return res end +local function filter(candidates, prefix) + return vim.tbl_filter(function(val) + return vim.startswith(val, prefix) + end, candidates) +end + +local notmuch_mailroot = nil + -- returns the notmuch database root, or nil in case of error ---@return string|nil local function get_mailroot() - if vim.g.notmuch_mailroot then - return vim.g.notmuch_mailroot + if notmuch_mailroot then + return notmuch_mailroot end local nm_get_config = vim.system({ "notmuch", "config", "get", "database.mail_root" }):wait() @@ -45,87 +49,92 @@ local function get_mailroot() ) return end - return vim.trim(nm_get_config.stdout) + notmuch_mailroot = vim.trim(nm_get_config.stdout) + return notmuch_mailroot end function cmdline_cmp.comp_search_terms(arglead, _, _) - if starts_with(arglead, "tag:") then - local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) - return vim.tbl_map(function(t) - return "tag:" .. t - end, tags) - elseif starts_with(arglead, "is:") then - local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) - return vim.tbl_map(function(t) - return "is:" .. t - end, tags) - elseif starts_with(arglead, "mimetype:") then - local mimetypes = { - "application/", "audio/", "chemical/", "font/", "image/", - "inode/", "message/", "model/", "multipart/", "text/", "video/", - } - return vim.tbl_map(function(m) - return "mimetype:" .. m - end, mimetypes) - elseif starts_with(arglead, "from:") then - local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) - return vim.tbl_map(function(a) - return "from:" .. a - end, addrs) - elseif starts_with(arglead, "to:") then - local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) - return vim.tbl_map(function(a) - return "to:" .. a - end, addrs) - elseif starts_with(arglead, "folder:") then - local mailroot = get_mailroot() - if not mailroot then return {} end - - local dirs = vim.fn.systemlist({ - "find", mailroot, "-type", "d", "-name", "cur", - }) - - local folders = {} - local pattern = "^" .. vim.pesc(mailroot) .. "/?" - - for _, dir in ipairs(dirs) do - local parent = vim.fn.fnamemodify(dir, ":h") - local rel = parent:gsub(pattern, "") - if rel ~= "" then - folders[#folders + 1] = "folder:" .. quote_if_needed(rel) + local function fetch_completions() + if vim.startswith(arglead, "tag:") then + local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) + return vim.tbl_map(function(t) + return "tag:" .. t + end, tags) + elseif vim.startswith(arglead, "is:") then + local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) + return vim.tbl_map(function(t) + return "is:" .. t + end, tags) + elseif vim.startswith(arglead, "mimetype:") then + local mimetypes = { + "application/", "audio/", "chemical/", "font/", "image/", + "inode/", "message/", "model/", "multipart/", "text/", "video/", + } + return vim.tbl_map(function(m) + return "mimetype:" .. m + end, mimetypes) + elseif vim.startswith(arglead, "from:") then + local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) + return vim.tbl_map(function(a) + return "from:" .. a + end, addrs) + elseif vim.startswith(arglead, "to:") then + local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) + return vim.tbl_map(function(a) + return "to:" .. a + end, addrs) + elseif vim.startswith(arglead, "folder:") then + local mailroot = get_mailroot() + if not mailroot then return {} end + + local dirs = vim.fn.systemlist({ + "find", mailroot, "-type", "d", "-name", "cur", + }) + + local folders = {} + local pattern = "^" .. vim.pesc(mailroot) .. "/?" + + for _, dir in ipairs(dirs) do + local parent = vim.fn.fnamemodify(dir, ":h") + local rel = parent:gsub(pattern, "") + if rel ~= "" then + folders[#folders + 1] = "folder:" .. quote_if_needed(rel) + end end - end - return uniq_sorted(folders) - elseif starts_with(arglead, "path:") then - local mailroot = get_mailroot() - if not mailroot then return {} end + return uniq_sorted(folders) + elseif vim.startswith(arglead, "path:") then + local mailroot = get_mailroot() + if not mailroot then return {} end - local dirs = vim.fn.systemlist({ "find", mailroot, "-type", "d" }) + local dirs = vim.fn.systemlist({ "find", mailroot, "-type", "d" }) - local paths = {} - local pattern = "^" .. vim.pesc(mailroot) .. "/?" + local paths = {} + local pattern = "^" .. vim.pesc(mailroot) .. "/?" - for _, dir in ipairs(dirs) do - local rel = dir:gsub(pattern, "") - if rel ~= "" then - paths[#paths + 1] = "path:" .. quote_if_needed(rel) + for _, dir in ipairs(dirs) do + local rel = dir:gsub(pattern, "") + if rel ~= "" then + paths[#paths + 1] = "path:" .. quote_if_needed(rel) + end end + + return uniq_sorted(paths) end - return uniq_sorted(paths) + -- default: search terms + return search_terms_list end - -- default: search terms - return search_terms_list + return filter(fetch_completions(), arglead) end -function cmdline_cmp.comp_tags(_, _, _) - return vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) +function cmdline_cmp.comp_tags(arglead, _, _) + return filter(vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }), arglead) end -function cmdline_cmp.comp_address(_, _, _) - return vim.fn.systemlist({ "notmuch", "address", "*" }) +function cmdline_cmp.comp_address(arglead, _, _) + return filter(vim.fn.systemlist({ "notmuch", "address", "*" }), arglead) end return cmdline_cmp From d5453f5fa8d325ee683be11fd62cc9305334cb29 Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Fri, 6 Feb 2026 22:08:20 +0300 Subject: [PATCH 18/23] refactor(completion): rename cmdline_completions module to completion --- lua/notmuch/cmdline_completions.lua | 140 ---------------------------- lua/notmuch/completion.lua | 140 ++++++++++++++++++++++++++++ lua/notmuch/init.lua | 6 +- 3 files changed, 143 insertions(+), 143 deletions(-) delete mode 100644 lua/notmuch/cmdline_completions.lua create mode 100644 lua/notmuch/completion.lua diff --git a/lua/notmuch/cmdline_completions.lua b/lua/notmuch/cmdline_completions.lua deleted file mode 100644 index feb9b1d..0000000 --- a/lua/notmuch/cmdline_completions.lua +++ /dev/null @@ -1,140 +0,0 @@ -local cmdline_cmp = {} - -local search_terms_list = { - "attachment:", "folder:", "id:", "mimetype:", - "property:", "subject:", "thread:", "date:", "from:", "lastmod:", - "path:", "query:", "tag:", "is:", "to:", "body:", "and ", "or ", "not ", -} - -local function quote_if_needed(s) - if s:find("[ %[%]]") then - return '"' .. s .. '"' - end - return s -end - -local function uniq_sorted(list) - table.sort(list) - local res = {} - local prev - for _, v in ipairs(list) do - if v ~= prev then - res[#res + 1] = v - prev = v - end - end - return res -end - -local function filter(candidates, prefix) - return vim.tbl_filter(function(val) - return vim.startswith(val, prefix) - end, candidates) -end - -local notmuch_mailroot = nil - --- returns the notmuch database root, or nil in case of error ----@return string|nil -local function get_mailroot() - if notmuch_mailroot then - return notmuch_mailroot - end - - local nm_get_config = vim.system({ "notmuch", "config", "get", "database.mail_root" }):wait() - if nm_get_config.code ~= 0 or #nm_get_config.stdout == 0 then - vim.notify( - "notmuch.nvim: Failed to get database.mail_root from notmuch config", - vim.log.levels.ERROR - ) - return - end - notmuch_mailroot = vim.trim(nm_get_config.stdout) - return notmuch_mailroot -end - -function cmdline_cmp.comp_search_terms(arglead, _, _) - local function fetch_completions() - if vim.startswith(arglead, "tag:") then - local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) - return vim.tbl_map(function(t) - return "tag:" .. t - end, tags) - elseif vim.startswith(arglead, "is:") then - local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) - return vim.tbl_map(function(t) - return "is:" .. t - end, tags) - elseif vim.startswith(arglead, "mimetype:") then - local mimetypes = { - "application/", "audio/", "chemical/", "font/", "image/", - "inode/", "message/", "model/", "multipart/", "text/", "video/", - } - return vim.tbl_map(function(m) - return "mimetype:" .. m - end, mimetypes) - elseif vim.startswith(arglead, "from:") then - local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) - return vim.tbl_map(function(a) - return "from:" .. a - end, addrs) - elseif vim.startswith(arglead, "to:") then - local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) - return vim.tbl_map(function(a) - return "to:" .. a - end, addrs) - elseif vim.startswith(arglead, "folder:") then - local mailroot = get_mailroot() - if not mailroot then return {} end - - local dirs = vim.fn.systemlist({ - "find", mailroot, "-type", "d", "-name", "cur", - }) - - local folders = {} - local pattern = "^" .. vim.pesc(mailroot) .. "/?" - - for _, dir in ipairs(dirs) do - local parent = vim.fn.fnamemodify(dir, ":h") - local rel = parent:gsub(pattern, "") - if rel ~= "" then - folders[#folders + 1] = "folder:" .. quote_if_needed(rel) - end - end - - return uniq_sorted(folders) - elseif vim.startswith(arglead, "path:") then - local mailroot = get_mailroot() - if not mailroot then return {} end - - local dirs = vim.fn.systemlist({ "find", mailroot, "-type", "d" }) - - local paths = {} - local pattern = "^" .. vim.pesc(mailroot) .. "/?" - - for _, dir in ipairs(dirs) do - local rel = dir:gsub(pattern, "") - if rel ~= "" then - paths[#paths + 1] = "path:" .. quote_if_needed(rel) - end - end - - return uniq_sorted(paths) - end - - -- default: search terms - return search_terms_list - end - - return filter(fetch_completions(), arglead) -end - -function cmdline_cmp.comp_tags(arglead, _, _) - return filter(vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }), arglead) -end - -function cmdline_cmp.comp_address(arglead, _, _) - return filter(vim.fn.systemlist({ "notmuch", "address", "*" }), arglead) -end - -return cmdline_cmp diff --git a/lua/notmuch/completion.lua b/lua/notmuch/completion.lua new file mode 100644 index 0000000..3f8bb5d --- /dev/null +++ b/lua/notmuch/completion.lua @@ -0,0 +1,140 @@ +local C = {} + +local search_terms_list = { + "attachment:", "folder:", "id:", "mimetype:", + "property:", "subject:", "thread:", "date:", "from:", "lastmod:", + "path:", "query:", "tag:", "is:", "to:", "body:", "and ", "or ", "not ", +} + +local function quote_if_needed(s) + if s:find("[ %[%]]") then + return '"' .. s .. '"' + end + return s +end + +local function uniq_sorted(list) + table.sort(list) + local res = {} + local prev + for _, v in ipairs(list) do + if v ~= prev then + res[#res + 1] = v + prev = v + end + end + return res +end + +local function filter(candidates, prefix) + return vim.tbl_filter(function(val) + return vim.startswith(val, prefix) + end, candidates) +end + +local notmuch_mailroot = nil + +-- returns the notmuch database root, or nil in case of error +---@return string|nil +local function get_mailroot() + if notmuch_mailroot then + return notmuch_mailroot + end + + local nm_get_config = vim.system({ "notmuch", "config", "get", "database.mail_root" }):wait() + if nm_get_config.code ~= 0 or #nm_get_config.stdout == 0 then + vim.notify( + "notmuch.nvim: Failed to get database.mail_root from notmuch config", + vim.log.levels.ERROR + ) + return + end + notmuch_mailroot = vim.trim(nm_get_config.stdout) + return notmuch_mailroot +end + +function C.comp_search_terms(arglead, _, _) + local function fetch_completions() + if vim.startswith(arglead, "tag:") then + local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) + return vim.tbl_map(function(t) + return "tag:" .. t + end, tags) + elseif vim.startswith(arglead, "is:") then + local tags = vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }) + return vim.tbl_map(function(t) + return "is:" .. t + end, tags) + elseif vim.startswith(arglead, "mimetype:") then + local mimetypes = { + "application/", "audio/", "chemical/", "font/", "image/", + "inode/", "message/", "model/", "multipart/", "text/", "video/", + } + return vim.tbl_map(function(m) + return "mimetype:" .. m + end, mimetypes) + elseif vim.startswith(arglead, "from:") then + local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) + return vim.tbl_map(function(a) + return "from:" .. a + end, addrs) + elseif vim.startswith(arglead, "to:") then + local addrs = vim.fn.systemlist({ "notmuch", "address", "*" }) + return vim.tbl_map(function(a) + return "to:" .. a + end, addrs) + elseif vim.startswith(arglead, "folder:") then + local mailroot = get_mailroot() + if not mailroot then return {} end + + local dirs = vim.fn.systemlist({ + "find", mailroot, "-type", "d", "-name", "cur", + }) + + local folders = {} + local pattern = "^" .. vim.pesc(mailroot) .. "/?" + + for _, dir in ipairs(dirs) do + local parent = vim.fn.fnamemodify(dir, ":h") + local rel = parent:gsub(pattern, "") + if rel ~= "" then + folders[#folders + 1] = "folder:" .. quote_if_needed(rel) + end + end + + return uniq_sorted(folders) + elseif vim.startswith(arglead, "path:") then + local mailroot = get_mailroot() + if not mailroot then return {} end + + local dirs = vim.fn.systemlist({ "find", mailroot, "-type", "d" }) + + local paths = {} + local pattern = "^" .. vim.pesc(mailroot) .. "/?" + + for _, dir in ipairs(dirs) do + local rel = dir:gsub(pattern, "") + if rel ~= "" then + paths[#paths + 1] = "path:" .. quote_if_needed(rel) + end + end + + return uniq_sorted(paths) + end + + -- default: search terms + return search_terms_list + end + + return filter(fetch_completions(), arglead) +end + +function C.comp_tags(arglead, _, _) + return filter(vim.fn.systemlist({ "notmuch", "search", "--output=tags", "*" }), arglead) +end + +function C.comp_address(arglead, _, _) + return filter(vim.fn.systemlist({ "notmuch", "address", "*" }), arglead) +end + +return C diff --git a/lua/notmuch/init.lua b/lua/notmuch/init.lua index 7a3f497..8c91004 100644 --- a/lua/notmuch/init.lua +++ b/lua/notmuch/init.lua @@ -44,21 +44,21 @@ nm.setup = function(opts) end, { desc = "Open inbox", nargs = "?", - complete = require("notmuch.cmdline_completions").comp_address + complete = require("notmuch.completion").comp_address }) vim.api.nvim_create_user_command("NmSearch", function(arg) nm.search_terms(arg.args) end, { desc = "Notmuch search", nargs = "*", - complete = require("notmuch.cmdline_completions").comp_search_terms + complete = require("notmuch.completion").comp_search_terms }) vim.api.nvim_create_user_command("ComposeMail", function(arg) require("notmuch.send").compose(arg.args) end, { desc = "Compose mail", nargs = "*", - complete = require("notmuch.cmdline_completions").comp_address + complete = require("notmuch.completion").comp_address }) end From b4bf8c8458d637fda15e732eec44e7f532e574c7 Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Fri, 6 Feb 2026 22:49:05 +0300 Subject: [PATCH 19/23] docs(changelog): document vimscript to lua migration Add changelog entries describing the completion of the major refactoring that removed all Vimscript dependencies and migrated to pure Lua implementation with improved completion filtering. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ddc13f..9de22fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Eliminates redundant buffer scans for message operations - Database opening now uses `notmuch_database_open_with_config` when available, with fallback to deprecated `notmuch_database_open` - Version detection runs once at module load for improved performance +- **Major refactoring**: Completed migration from Vimscript to pure Lua implementation + - Deleted `plugin/notmuch.vim` and `autoload/notmuch.vim` (Vimscript plugin files) + - Ported all command definitions to Lua using `vim.api.nvim_create_user_command` + - Ported command-line completion functions from Vimscript to Lua module (`completion.lua`) + - Completion functions now filter results based on user input for better UX + - Removed dependency on Vimscript autoload functions and global Vim command registration ### Deprecated From 84b1104280adb26abefcd3f26d72e7bbba405236 Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Fri, 6 Feb 2026 22:59:37 +0300 Subject: [PATCH 20/23] docs: update documentation for vimscript to lua migration Remove references to deleted plugin/notmuch.vim and autoload/notmuch.vim files. Reflect command definitions now in init.lua using vim.api.nvim_create_user_command. Update doc/notmuch.txt COMPLETION section to reference the new completion.lua Lua module instead of the old autoload function. Add completion.lua module documentation to the LUA MODULES section describing its purpose and exported functions. --- doc/notmuch.txt | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/doc/notmuch.txt b/doc/notmuch.txt index 712826a..7b98c1b 100644 --- a/doc/notmuch.txt +++ b/doc/notmuch.txt @@ -355,22 +355,20 @@ option will be listed with its default value. ------------------------------------------------------------------------------ COMPLETION *notmuch-completion* -Notmuch.nvim provides a handy autoload function that returns completion items -for commands. Specifcially this is used for |:NmSearch| as it helps quickly -type search terms. The function in question is called -`notmuch#CompSearchTerms()` and matches the format for generic completion -functons (|command-completion-custom|). - -Although this is used for |:NmSearch|, you can actually use this as a -completion function for any custom commands you may want to define. The -completion function returns a few types of results: - - * If the word under the cursor says 'tag:', returns a list of all the tags - available in the notmuch database. +Notmuch.nvim provides completion functions for commands via the `completion.lua` +Lua module. These are used for |:NmSearch|, |:Inbox|, and |:ComposeMail| to help +quickly type search terms and addresses. The completion module exposes functions +like `comp_search_terms()`, `comp_tags()`, and `comp_address()`. + +The completion functions return filtered results based on what the user has typed +so far. The completion function returns a few types of results: + + * If the word under the cursor starts with 'tag:', returns a list of all the + tags available in the notmuch database. * Similar completions are done intelligently for search terms like 'is:', - 'mimetype', 'folder:', 'path:'. + 'mimetype:', 'folder:', 'path:'. * Address completion can be used with 'from:', 'to:'. Especially useful for - composing emails too. + composing emails and filtering by recipient. * Otherwise returns a list of all the valid search terms (see `notmuch-search-terms(1)`). @@ -603,6 +601,13 @@ plugin's project codebase. - Defines the default configuration options. - Implements configuration option overrides set by the user. + - **completion.lua**: + - Provides command-line completion functions for user commands. + - Implements `comp_search_terms()` for |:NmSearch| command completion. + - Implements `comp_tags()` for tag-related completion. + - Implements `comp_address()` for email address completion. + - Filters completion results based on user input for better UX. + - **thread.lua**: - Handles JSON-based thread display and parsing. - Runs `notmuch show --format=json` and transforms output into buffer From 5374489346a08fa046eb7f86ef02a13484c81a80 Mon Sep 17 00:00:00 2001 From: Yousef Akbar Date: Fri, 6 Feb 2026 23:38:47 +0300 Subject: [PATCH 21/23] refactor(ftplugin): migrate tag completion to lua module Update TagAdd/TagRm/TagToggle commands in ftplugin files to use the new notmuch.completion.comp_tags Lua function instead of the deprecated notmuch#CompTags Vimscript autoload function. Use customlist completion type for proper handling of Lua table returns. --- ftplugin/mail.vim | 6 +++--- ftplugin/notmuch-threads.vim | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ftplugin/mail.vim b/ftplugin/mail.vim index 80db183..2852d54 100644 --- a/ftplugin/mail.vim +++ b/ftplugin/mail.vim @@ -3,9 +3,9 @@ if match(bufname("%"), "^thread:") != -1 setlocal foldmethod=marker setlocal foldlevel=0 - command -buffer -complete=custom,notmuch#CompTags -nargs=+ TagAdd :call v:lua.require('notmuch.tag').msg_add_tag("") - command -buffer -complete=custom,notmuch#CompTags -nargs=+ TagRm :call tag.msg_rm_tag("") - command -buffer -complete=custom,notmuch#CompTags -nargs=+ TagToggle :call tag.msg_toggle_tag("") + command -buffer -complete=customlist,v:lua.require'notmuch.completion'.comp_tags -nargs=+ TagAdd :call v:lua.require('notmuch.tag').msg_add_tag("") + command -buffer -complete=customlist,v:lua.require'notmuch.completion'.comp_tags -nargs=+ TagRm :call tag.msg_rm_tag("") + command -buffer -complete=customlist,v:lua.require'notmuch.completion'.comp_tags -nargs=+ TagToggle :call tag.msg_toggle_tag("") 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() diff --git a/ftplugin/notmuch-threads.vim b/ftplugin/notmuch-threads.vim index 977dd28..60beda3 100644 --- a/ftplugin/notmuch-threads.vim +++ b/ftplugin/notmuch-threads.vim @@ -5,9 +5,9 @@ let r = v:lua.require('notmuch.refresh') let s = v:lua.require('notmuch.sync') let tag = v:lua.require('notmuch.tag') -command -buffer -range -complete=custom,notmuch#CompTags -nargs=+ TagAdd :call tag.thread_add_tag(, , ) -command -buffer -range -complete=custom,notmuch#CompTags -nargs=+ TagRm :call tag.thread_rm_tag(, , ) -command -buffer -range -complete=custom,notmuch#CompTags -nargs=+ TagToggle :call tag.thread_toggle_tag(, , ) +command -buffer -range -complete=customlist,v:lua.require'notmuch.completion'.comp_tags -nargs=+ TagAdd :call tag.thread_add_tag(, , ) +command -buffer -range -complete=customlist,v:lua.require'notmuch.completion'.comp_tags -nargs=+ TagRm :call tag.thread_rm_tag(, , ) +command -buffer -range -complete=customlist,v:lua.require'notmuch.completion'.comp_tags -nargs=+ TagToggle :call tag.thread_toggle_tag(, , ) command -buffer -range DelThread :call tag.thread_add_tag("del", , ) | :call tag.thread_rm_tag("inbox", , ) nnoremap call nm.show_thread() From 23245c9873301e904a88552b5f417692f776b3cd Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Wed, 18 Feb 2026 16:28:32 +0100 Subject: [PATCH 22/23] feat(thread): add configurable message order in thread view Introduce a `message_order` config option to control the order of messages displayed in the thread view. Users can choose between "newest-first" and "oldest-first" to suit their preference. When set to "newest-first", the thread view flattens and sorts messages by timestamp in descending order. Default behavior maintains the original hierarchical order. --- lua/notmuch/config.lua | 1 + lua/notmuch/thread.lua | 50 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) 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/thread.lua b/lua/notmuch/thread.lua index 1e90362..6addd9f 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 @@ -471,8 +473,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 From 91ada040961d268c682417f1a38f8f2d891998a9 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Thu, 19 Feb 2026 17:19:09 +0100 Subject: [PATCH 23/23] feat(compose): add oil.nvim-style attachment buffer Introduce a new attachment buffer for compose and reply workflows, modeled after oil.nvim. Users can add, remove, and paste file paths as attachments using intuitive keymaps and commands. The buffer supports direct file path entry, integration with oil.nvim for pasting/yanking, and validates attachments on save. Documentation and syntax highlighting for the new buffer are included. Also refactor attachment commands and sending logic to use the new buffer and improve user feedback. --- README.md | 76 ++++- doc/notmuch.txt | 125 +++++++- ftplugin/notmuch-attachments.vim | 24 ++ lua/notmuch/attach_buffer.lua | 529 +++++++++++++++++++++++++++++++ lua/notmuch/attach_cmd.lua | 31 +- lua/notmuch/send.lua | 236 ++++---------- syntax/notmuch-attachments.vim | 28 ++ 7 files changed, 852 insertions(+), 197 deletions(-) create mode 100644 ftplugin/notmuch-attachments.vim create mode 100644 lua/notmuch/attach_buffer.lua create mode 100644 syntax/notmuch-attachments.vim 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/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_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/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/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"