From 3f38d1d04e3b58c623656b9ad0a1f3e907c0e625 Mon Sep 17 00:00:00 2001 From: th3r00t Date: Sun, 9 Aug 2026 19:56:33 -0400 Subject: [PATCH] feat: add NeoMutt-gated Neovim mail workflow --- init.lua | 1 + lua/mail.lua | 152 ++++++++++++++++++++++++++++++++++++++++++++ lua/plugins.lua | 1 + tests/mail_spec.lua | 47 ++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 lua/mail.lua create mode 100644 tests/mail_spec.lua diff --git a/init.lua b/init.lua index 4ea2d9b..cd28353 100644 --- a/init.lua +++ b/init.lua @@ -18,6 +18,7 @@ require("autocmds") -- ./lua/autocmds.lua require("diagnostics") -- ./lua/diagnostics.lua require("avante_settings") -- ./lua/avante_settings.lua require('telescope_configuration') -- ./lua/telescope_configuration.lua +require("mail") -- ./lua/mail.lua (no-op unless NEOMUTT=1) local function cyberdream() require("cyberdream").setup({ diff --git a/lua/mail.lua b/lua/mail.lua new file mode 100644 index 0000000..b55f56a --- /dev/null +++ b/lua/mail.lua @@ -0,0 +1,152 @@ +-- Mail compose mode: NeoMutt + nvim-mail integration. +-- Activates ONLY when the NEOMUTT environment variable is set. +-- In neomuttrc: set editor = "env NEOMUTT=1 nvim" +-- +-- Notmuch: optional indexed search only via mn. Never initialised or synced. +-- mbsync: never invoked. + +local M = {} + +-- Exact-match guard: only "1" activates mail mode. +-- Any other value (empty, "yes", "true") leaves this module dormant. +local function in_neomutt() + return vim.env.NEOMUTT == "1" +end + +if not in_neomutt() then + return M +end + +-- === NeoMutt session active === + +local function is_tty() + return vim.env.TERM == "linux" +end + +-- Configure nvim-mail (idempotent: the plugin guards with _configured flag). +local ok_mail, mail_plugin = pcall(require, "nvim-mail") +if ok_mail then + mail_plugin.setup({ + prefix = ",m", + spell_langs = { "en" }, + from_list = {}, + send_accounts = {}, + contacts = { cmd = "khard", args = { "email", "-p", "--remove-first-line" } }, + snippets = nil, + }) +end + +-- FileType autocmd: 72-column compose formatting + quote-depth highlighting. +-- nvim-mail's attach_buffer() handles spell, wrap, marker extmarks, and +-- BufWritePre guardrails (empty To/Subject, attachment mention check). +local au = vim.api.nvim_create_augroup("NeoMuttMail", { clear = true }) +vim.api.nvim_create_autocmd("FileType", { + group = au, + pattern = "mail", + callback = function() + vim.opt_local.textwidth = 72 + vim.opt_local.formatoptions = "tcqwan" + vim.opt_local.wrap = true + vim.opt_local.linebreak = true + vim.opt_local.spell = true + vim.opt_local.spelllang = "en" + -- colorcolumn is meaningful only outside TTY (linux console ignores it) + if not is_tty() then + vim.opt_local.colorcolumn = "73" + end + -- Quote-depth colouring: works with both cterm (TTY) and gui colours + vim.cmd([[ + hi def MailQuoted1 ctermfg=6 guifg=#61afef + hi def MailQuoted2 ctermfg=2 guifg=#98c379 + hi def MailQuoted3 ctermfg=3 guifg=#e5c07b + syn match MailQuoted3 /^>>>\+.*$/ + syn match MailQuoted2 /^>>[^>].*$\|^>>$/ + syn match MailQuoted1 /^>[^>].*$\|^>$/ + ]]) + end, +}) + +-- m group: mail compose actions (supplement to nvim-mail's ,m prefix). +-- Descriptions are plain ASCII so they render correctly in TTY via which-key. +local km = function(lhs, rhs, desc) + vim.keymap.set("n", lhs, rhs, { desc = desc }) +end + +km("m", "", "Mail") + +-- Safe wrapper: pcall so the keymap degrades gracefully if plugin not yet downloaded. +local function nav(fn, ...) + local args = { ... } + local ok, nav_mod = pcall(require, "nvim-mail.navigate") + if ok then nav_mod[fn](table.unpack(args)) end +end + +km("mt", function() nav("goto_field", "^[Tt]o:", "A") end, "Mail: To:") +km("mc", function() nav("goto_field", "^[Cc]c:", "A") end, "Mail: Cc:") +km("mf", function() nav("goto_field", "^[Ff]rom:", "A") end, "Mail: From:") +km("ms", function() nav("goto_field", "^[Ss]ubject:", "A") end, "Mail: Subject:") +km("mb", function() nav("goto_body") end, "Mail: Body") +km("mS", function() nav("goto_signature") end, "Mail: Signature") +km("mr", function() nav("goto_reply") end, "Mail: Quoted reply") +km("me", function() nav("goto_end_of_reply") end, "Mail: End of reply") +km("mk", function() nav("kill_quoted_sig") end, "Mail: Kill quoted sig") + +-- Attachment guardrail: on-demand explicit check (BufWritePre also fires automatically) +km("ma", function() + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local ok_att, att = pcall(require, "nvim-mail.attachment") + if not ok_att then + vim.notify("[mail] nvim-mail.attachment unavailable", vim.log.levels.ERROR) + return + end + local missing, match = att.check(lines) + if missing then + vim.notify( + string.format('[mail] Attachment mentioned ("%s") but no file attached!', match or "attach"), + vim.log.levels.WARN + ) + else + vim.notify("[mail] Attachment check: OK", vim.log.levels.INFO) + end +end, "Mail: Attachment check") + +-- Header reminder: warn if To: or Subject: are empty +km("mR", function() + local lines = vim.api.nvim_buf_get_lines(0, 0, 20, false) + local to, subj = false, false + for _, l in ipairs(lines) do + if l == "" then break end + if l:match("^[Tt]o:%s*.+") then to = true end + if l:match("^[Ss]ubject:%s*.+") then subj = true end + end + local issues = {} + if not to then issues[#issues + 1] = "To: is empty" end + if not subj then issues[#issues + 1] = "Subject: is empty" end + if #issues == 0 then + vim.notify("[mail] Headers OK", vim.log.levels.INFO) + else + vim.notify("[mail] " .. table.concat(issues, " | "), vim.log.levels.WARN) + end +end, "Mail: Header reminder") + +-- Notmuch: optional indexed search only. Never initialised, never synced. +-- Opens results in a scratch buffer; does not touch the notmuch database. +km("mn", function() + if vim.fn.executable("notmuch") == 0 then + vim.notify("[mail] notmuch not available (optional dependency)", vim.log.levels.WARN) + return + end + vim.ui.input({ prompt = "Notmuch search: " }, function(query) + if not query or query == "" then return end + local result = vim.fn.systemlist({ "notmuch", "search", "--output=summary", query }) + vim.cmd("new") + vim.api.nvim_buf_set_lines(0, 0, -1, false, result) + vim.bo.buftype = "nofile" + vim.bo.bufhidden = "wipe" + vim.bo.modifiable = false + vim.bo.swapfile = false + pcall(vim.api.nvim_buf_set_name, 0, "Notmuch: " .. query) + end) +end, "Mail: Notmuch search") + +return M diff --git a/lua/plugins.lua b/lua/plugins.lua index a09cb5e..63b670c 100644 --- a/lua/plugins.lua +++ b/lua/plugins.lua @@ -69,6 +69,7 @@ vim.pack.add({ { src = "https://github.com/lewis6991/gitsigns.nvim" }, { src = "https://github.com/amitds1997/remote-nvim.nvim" }, { src = "https://github.com/epwalsh/obsidian.nvim" }, + { src = "https://github.com/monkeyxite/nvim-mail", version = "03b9d7d1eea8e6a06cd7f3e477e17f164d267ea5" }, }) require('mini.icons').setup({}) diff --git a/tests/mail_spec.lua b/tests/mail_spec.lua new file mode 100644 index 0000000..23325a2 --- /dev/null +++ b/tests/mail_spec.lua @@ -0,0 +1,47 @@ +-- Test: lua/mail.lua NEOMUTT=1 activation +-- Run from repo root: NEOMUTT=1 nvim --headless -u NONE -i NONE --noplugin -l tests/mail_spec.lua +-- Exit 0 = all assertions passed; non-zero = failure. +-- Does NOT require nvim-mail to be installed (pcall-guarded in mail.lua). + +vim.g.mapleader = " " + +-- Resolve repo root from this file's path and expose lua/ to require() +local src = debug.getinfo(1, "S").source:sub(2) -- strip leading '@' +local repo = vim.fn.fnamemodify(src, ":h:h") -- tests/../ = repo root +package.path = repo .. "/lua/?.lua;" .. package.path + +-- 1. Module loads when NEOMUTT=1 is present in the environment +local ok, err = pcall(require, "mail") +assert(ok, "require('mail') failed: " .. tostring(err)) +print("[pass] mail module loaded") + +-- 2. Create a scratch buffer, make it current, set filetype to fire the autocmd +local buf = vim.api.nvim_create_buf(false, true) +vim.api.nvim_set_current_buf(buf) +vim.cmd("set filetype=mail") + +-- 3. Buffer-local option checks (set via vim.opt_local in the FileType callback) +local tw = vim.bo.textwidth +assert(tw == 72, ("textwidth: expected 72, got %d"):format(tw)) +print("[pass] textwidth = 72") + +local fo = vim.bo.formatoptions +assert(fo:find("q", 1, true), ("formatoptions %q missing 'q'"):format(fo)) +print("[pass] formatoptions contains 'q'") + +-- 4. Window-local option checks (wrap and spell are 'wo', not 'bo') +assert(vim.wo.wrap, "wrap should be true") +print("[pass] wrap = true") + +assert(vim.wo.spell, "spell should be true") +print("[pass] spell = true") + +-- 5. Representative keymap registered at global scope by mail.lua +local found_mt = false +for _, m in ipairs(vim.api.nvim_get_keymap("n")) do + if m.desc == "Mail: To:" then found_mt = true; break end +end +assert(found_mt, "mt (Mail: To:) keymap not registered") +print("[pass] mt keymap registered") + +print("\nALL TESTS PASSED")