From 6010880b54ad66e3dd6d75e6655a4ec5e3f9a8fe Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Mon, 19 Jan 2026 15:13:42 +0100 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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