feat: add NeoMutt-gated Neovim mail workflow
This commit is contained in:
1
init.lua
1
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({
|
||||
|
||||
152
lua/mail.lua
Normal file
152
lua/mail.lua
Normal file
@@ -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 <leader>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,
|
||||
})
|
||||
|
||||
-- <leader>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("<leader>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("<leader>mt", function() nav("goto_field", "^[Tt]o:", "A") end, "Mail: To:")
|
||||
km("<leader>mc", function() nav("goto_field", "^[Cc]c:", "A") end, "Mail: Cc:")
|
||||
km("<leader>mf", function() nav("goto_field", "^[Ff]rom:", "A") end, "Mail: From:")
|
||||
km("<leader>ms", function() nav("goto_field", "^[Ss]ubject:", "A") end, "Mail: Subject:")
|
||||
km("<leader>mb", function() nav("goto_body") end, "Mail: Body")
|
||||
km("<leader>mS", function() nav("goto_signature") end, "Mail: Signature")
|
||||
km("<leader>mr", function() nav("goto_reply") end, "Mail: Quoted reply")
|
||||
km("<leader>me", function() nav("goto_end_of_reply") end, "Mail: End of reply")
|
||||
km("<leader>mk", function() nav("kill_quoted_sig") end, "Mail: Kill quoted sig")
|
||||
|
||||
-- Attachment guardrail: on-demand explicit check (BufWritePre also fires automatically)
|
||||
km("<leader>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("<leader>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("<leader>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
|
||||
@@ -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({})
|
||||
|
||||
47
tests/mail_spec.lua
Normal file
47
tests/mail_spec.lua
Normal file
@@ -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, "<leader>mt (Mail: To:) keymap not registered")
|
||||
print("[pass] <leader>mt keymap registered")
|
||||
|
||||
print("\nALL TESTS PASSED")
|
||||
Reference in New Issue
Block a user