-- -- The global lumail configuration-file -- ----------------------------------------------------------------------------- ----------------------------------------------------------------------------- -- -- This is global configuration file for the lumail command-line email -- client, which is configured solely by Lua. -- -- The client will load two files at startup if they exist: -- -- 1. The global configuration-file: -- -- * /etc/lumail2/lumail2.lua -- * /etc/lumail/lumail.lua -- -- 2. The per-user configuration file, which is one of: -- -- * $XDG_CONFIG_HOME/lumail/lumail.lua -- * $XDG_CONFIG_HOME/lumail/config.lua -- * $XDG_CONFIG_HOME/lumail/$HOSTNAME.lua -- -- * ~/.config/lumail/lumail.lua -- * ~/.config/lumail/config.lua -- * ~/.config/lumail/$HOSTNAME.lua -- -- * ~/.lumail/lumail.lua -- * ~/.lumail/config.lua -- * ~/.lumail/$HOSTNAME.lua -- -- The expectation is that you will NOT EDIT this file, instead -- you will place your own configuration in one of those locations. -- -- -- CONFIGURATION ---------------- -- -- While you may edit this file it is recommended you do not, instead you -- should copy the sample file `user.config.lua` into one of the locations -- documented above, and then edit it. -- -- If the above is too complex, or you're unsure, then this is safe: -- -- mkdir -p ~/.lumail/lumail.lua -- cp user.config.lua ~/.lumail/lumail.lua -- -- Or : -- -- mkdir -p ~/.lumail/ -- cp user.config.lua ~/.lumail/$(hostname --fqdn).lua -- -- This will make upgrading less painful. -- -- -- DOCUMENTATION ---------------- -- -- For reference to the lumail2 objects please see the API documentation -- included in the file `API.md`, or browsable online at: -- -- http://lumail.org/api/ -- -- -- Steve -- -- -- -- -- -- Load libraries -- Fun = require "functional" Life = require "life" Stack = require "stack" keymap = require "keymap" Progress = require "progress_bar" Hcache = require "header_cache" Threader = require "threader" -- -- Load libraries which directly poke functions into the global -- namespace (bad but useful). -- SU = require "string_utilities" TU = require "table_utilities" -- -- GPG support will only be present if the `mimegpg`-binary is -- present upon the users' PATH. -- GPG = nil if string.path "mimegpg" then GPG = require "gpg" end -- -- Setup a cache for objects. -- cache = Cache.new() -- -- If the user changes the index-limit we'll try to keep the same -- maildir selected. We do that by caching the old value here. -- local prev_maildir = nil -- -- This contains the messages which are currently visible. -- -- When the user selects a Maildir, or changes the active selection -- via the `index.limit` setting, then this set of messages will be -- updated. -- local global_msgs = nil -- -- Define some utility functions -- ----------------------------------------------------------------------------- ----------------------------------------------------------------------------- -- -- Helper to write error messages to stderr & the panel. -- function on_error (msg) error_msg(msg) io.stderr:write(msg) end -- --- Append an error message to the panel. -- -- An error indicates that an operation wasn't completed correctly. -- function error_msg (msg) Panel:append("$[RED|BOLD]ERROR:$[WHITE] " .. msg) end -- --- Append an warning message to the panel. -- -- A warning indicates that an operation was completed correctly but not -- in the intended way. E.g. The deletion of all messages succeeded but -- there weren't any messages. -- function warning_msg (msg) Panel:append("$[RED]WARNING:$[WHITE] " .. msg) end -- --- Append an info message to the panel. -- -- An info is printed if information should be presented to the user, which -- he hasn't requested or as feedback on a operation he provoked. -- E.g. The time mail sorting took. That the user aborted the sending of a message. -- function info_msg (msg) Panel:append("$[YELLOW]INFO:$[WHITE] " .. msg) end -- -- Return the program which can be used to view/show a file which has -- the specified MIME-type. -- -- For example if the input is "image/jpeg" we'd expect this function -- to return something like "display %s". -- -- The caller is expected to replace "%s" with the name of the file -- which is to be viewed. -- -- Reference: https://en.wikipedia.org/wiki/Mailcap -- function get_mime_viewer (mime_type) -- -- The default viewer. -- local ret = "less %s" -- The file we lookup values in. local filename = Config.get_with_default("global.mime-file", "/etc/mailcap") -- Return the default value if the input file doesn't exist. if not File:exists(filename) then return ret end -- HTML will probably have a graphical helper -- we'll skip this for now. TODO: Fixme if mime_type == "text/html" then return ret end local f = io.input(filename) local line = nil -- -- Remember wildcard match -- local wildcard_exec for line in io.lines() do -- -- Split the line by ";", and if that worked -- local entries = string.split(line, ";") if #entries >= 2 then -- -- The values -- local type = string.trim(entries[1]) local exec = string.trim(entries[2]) -- -- Wildcards like "image/*" are used if no other match was found. -- if type == mime_type then f:close() return exec elseif type:sub(-2) == "/*" and type:sub(1, -3) == mime_type:split "/"[1] then wildcard_exec = exec end end end f:close() -- -- We didn't find an obvious match, return wildcard match or default. -- return wildcard_exec or ret end -- -- Helper function to ensure that if anything calls `os.exit` -- we reset the screen neatly, etc. -- function os.exit (code) if type(on_exit) == "function" then on_exit() end -- -- Get the cache-prefix -- local dir = Config:get "cache.prefix" if dir then -- -- Ensure the directory exists. -- if not Directory:exists(dir) then Directory:mkdir(dir) end -- -- Now write the cache beneath it. -- local file = dir .. "/" .. Config:get "global.version" cache:save(file) end -- -- Empty the cache, to avoid leaks, if it exists. -- -- (Why wouldn't it?) -- if cache then cache:empty() end -- -- Finally exit. -- Screen:exit() end -- -- Given a line add the given colour to it - removing any -- existing colour, unless that colour is "UNREAD". -- -- For example an input line such as "Bob" will be made red -- like so: -- -- local out = colour_line( "Bob", "red" ) -- print( out ) -- -- The result is: -- -- $[red]Bob -- -- If the input was "$[blue]Bob" then the blue would be stripped and -- updated, but if the input is "$[UNREAD]Bob" it will be left alone. -- function colour_line (line, colour) local c, r = string.match(line, "^%$%[([a-zA-Z]*)%](.*)$") if c and r then c = c:lower() if c ~= "unread" then line = "$[" .. colour .. "]" .. r end return line end line = "$[" .. colour .. "]" .. line return line end -- -- Return the contents of the named file. -- function File.contents (path) local f = io.open(path, "rb") if f then local content = f:read "*all" f:close() return content else return ("Error reading file: " .. file) end end -- -- If this function is defined it will be invoked any time -- a configuration-key has its value changed. -- -- The single argument will be the name of the key which has -- been updated - the value can be retrieved via Config:get, but -- remember that the value might be a string, an integer, or a table. -- function Config.key_changed (name, old) -- -- Obsolete setting? -- if name == "message.cache" then warning_msg "The configuration key 'message.cache' is obsolete!" return end -- -- If the cache-prefix has changed load the cache -- if name == "cache.prefix" then local cache_prefix = Config:get "cache.prefix" local file = cache_prefix .. "/" .. Config:get "global.version" if file and File:exists(file) then info_msg("Loading cache " .. file) cache:load(file) end return end -- -- If index.limit changes then we must flush our message cache. -- if name == "index.limit" then global_msgs = nil return end -- -- If the index-limit changes then we'll try to preserve the -- previously selected maildir. -- if name == "maildir.limit" then -- Get the current index, if that fails we're done local x = Config.get_with_default("maildir.current", -1) if x == -1 then return end -- Get all known maildirs - filtered by the limit which was -- PREVIOUSLY in place, using the value of old. local m = maildirs(old) -- Now we can find the folder at the point if m and (x + 1 <= #m) then prev_maildir = m[x + 1]:path() end end -- -- If the sorting method has changed we need to resort our messages. -- But only If we are in index view. Otherwise the messages get resorted when -- index view is selected. -- -- NOTE: We explicitly avoid re-reading the maildir, so we're -- just changing the order of the existing messages not refreshing -- them 100%. -- if name == "index.sort" and Config:get "global.mode" == "index" then global_msgs = sort_messages(global_msgs) return end end -- -- Given a key toggle the setting of that key from -- true -> false, and vice-versa. -- function Config.toggle (name) local current = Config:get(name) if current == nil then current = 0 end if (current == 1) or (current == "1") then current = 0 else current = 1 end Config:set(name, current) end -- -- Configuration values are retrieved/set via Config:get() and Config:set(). -- -- There is no built-in way of getting a value with a default, so we -- define a helper here to do the necessary thing. -- function Config.get_with_default (key, default) local value = Config:get(key) if value == nil then value = default end return value end -- -- Change the global mode, preserving the previous one -- on a stack, to allow undoing. -- do local mode_stack = Stack.new() -- Change to a new mode function change_mode (new_mode) -- Get the current mode local current = Config:get "global.mode" -- No change? Return if current == new_mode then return end -- Record the previous mode, and change. mode_stack:push(current) Config:set("global.mode", new_mode) end -- Restore to the previous mode function previous_mode () if Config:get("global.mode") == "index" then -- Write the header cache of this maildir to disk Hcache.save() end local prev = mode_stack:pop() if prev == nil then prev = "maildir" end Config:set("global.mode", prev) end end -- -- Get the current message. If the user is in message-mode then -- this is literally the currently selected message. -- -- If the user is in index-mode then this is the message under the -- current selection -- function Message.at_point () local mode = Config:get "global.mode" if mode == "message" then return (Global:current_message()) end if mode == "index" then -- Get the list of messages, and the current offset -- that'll let us find the message. local offset = Config.get_with_default("index.current", 0) local msgs = get_messages() if not msgs then error_msg "There are no messages!" return nil end return msgs[offset + 1] end return nil end -- -- Count the number of attachments a message has. -- function Message.count_attachments (msg) local parts = mimeparts2table(msg) local count = 0 for i, o in ipairs(parts) do if o['filename'] ~= nil and o['filename'] ~= "" then count = count + 1 end end return count end -- -- Get the creation-date of the message, in seconds past the epoch. -- -- We can cheat here by looking at the filename. If that fails then -- we parse the message by looking for the `Delivery-Date` and `Date` -- headers. -- function Message:to_ctime () local p = self:path() -- -- Lookup value in the cache, if we can. -- if cache:get(p .. "to_ctime") then return (tonumber(cache:get(p .. "to_ctime"))) end -- -- Look for the ctime in the message filename. -- local f = File:basename(p) local num = string.match(f, "^([0-9]+)%.") if num then -- Set the value in the cache, and return it. cache:set(p .. "to_ctime", num) return (tonumber(num)) end -- -- Otherwise parse the Received-Date + Date headers. -- local seconds = self:ctime() -- Set the value in the cache, and return it. cache:set(p .. "to_ctime", seconds) return seconds end -- -- Sort the specified table of message-objects. -- -- We expect that users might wish to define their own sorting methods -- so this function is intentionally open: -- -- Lookup the value of the sort-method (via `index.sort`). -- -- Call the function compare_by_$METHOD to do the comparison. -- -- If there is no `compare_by_foo` method then we return the table -- unsorted. -- function sort_messages (input) local method = Config:get "index.sort" if method == "none" then return input end -- -- Sorting a non-table? That's a failure -- if type(input) ~= "table" then return input end -- this global list is used in index_view to indent each message threads_indentation = {} -- -- Handle thread sorting and indentation -- if method == "threads" then local t_start = os.time() local res = {} res, threads_indentation = Threader.thread(input) local t_end = os.time() Panel:append("Sort method $[WHITE|BOLD]" .. method .. "$[WHITE] took $[WHITE|BOLD]" .. (t_end - t_start) .. "$[WHITE] seconds with " .. "$[WHITE|BOLD]" .. #input .. "$[WHITE] messages") return res else -- -- If the method is `file` we'll invoke `compare_by_file`, etc. -- local func = "compare_by_" .. method -- -- Is the desired sort-method defined? -- if _G[func] and (type(_G[func]) == "function") then -- -- If there is record the time, do the sort, and record the time again -- local t_start = os.time() table.sort(input, _G[func]) local t_end = os.time() -- Now show how long it took. Panel:append("Sort method $[WHITE|BOLD]" .. method .. "$[WHITE] took $[WHITE|BOLD]" .. (t_end - t_start) .. "$[WHITE] seconds with " .. "$[WHITE|BOLD]" .. #input .. "$[WHITE] messages") else -- -- There is no defined `compare_by_$foo` function. -- error_msg("Unknown sorting method " .. method) end end return input end -- -- Compare two messages, based upon the modification-time of their filenames. -- -- We make sure we compare numbers, as these might have been cached and -- saved as strings. -- -- Invoked when `index.sort` is set to `file`. -- -- NOTE: We use mtime here, rather than ctime, as the former won't change -- when a file is renamed. See #251 for details. -- function compare_by_file (a, b) Progress:step "Sorting messages" local a_path = a:path() local a_time = cache:get("compare_by_file" .. a_path) if a_time == nil then a_time = File:stat(a_path)['mtime'] cache:set("compare_by_file" .. a_path, a_time) end local b_path = b:path() local b_time = cache:get("compare_by_file" .. b_path) if b_time == nil then b_time = File:stat(b_path)['mtime'] cache:set("compare_by_file" .. b_path, b_time) end return tonumber(a_time) < tonumber(b_time) end -- -- Compare two messages, based upon their date-headers. -- -- We make sure we compare numbers, as these might have been cached and -- saved as strings. -- -- Invoked when `index.sort` is set to `date`. -- function compare_by_date (a, b) Progress:step "Sorting messages" local a_path = a:path() local a_date = cache:get("compare_by_date" .. a_path) if a_date == nil then a_date = a:to_ctime() cache:set("compare_by_date" .. a_path, a_date) end local b_path = b:path() local b_date = cache:get("compare_by_date" .. b_path) if b_date == nil then b_date = b:to_ctime() cache:set("compare_by_date" .. b_path, b_date) end -- -- Actually compare -- return tonumber(a_date) < tonumber(b_date) end -- -- Compare two messages, based upon their From-headers. -- -- Invoked when `index.sort` is set to `from`. -- function compare_by_from (a, b) Progress:step "Sorting messages" return (a:header("From") < b:header("From")) end -- -- Compare two messages, based upon subject-header. -- -- Invoked when `index.sort` is set to `subject`. -- function compare_by_subject (a, b) Progress:step "Sorting messages" return (a:header("Subject") < b:header("Subject")) end -- -- Utility method to change the sorting method, and flush our caches -- function sorting_method (value) if value then Config:set("index.sort", value) end return (Config:get "index.sort") end -- -- Return our maildirs -- function maildirs (limit) -- -- Get all maildirs -- local all = Global:maildirs() -- -- The return value. -- local ret = {} -- -- Filter them according to the limit - if we don't have a limit -- then use the global-one. -- if not limit then limit = Config.get_with_default("maildir.limit", "all") end if limit == "all" then -- -- All -- for i, o in ipairs(all) do table.insert(ret, o) end elseif limit == "new" then -- -- New -- for i, o in ipairs(all) do local unread = o:unread_messages() if unread > 0 then table.insert(ret, o) end end elseif limit == "today" then -- -- Today -- local time = os.time() local today = time - (60 * 60 * 24) for i, o in ipairs(all) do -- get modification-time of the maildir local mtime = o:mtime() -- if it was within the past 24 hours then add it if mtime > today then table.insert(ret, o) end end else -- -- "Pattern" -- for i, o in ipairs(all) do local fmt = o:format() if string.find(fmt, limit) then table.insert(ret, o) end end end -- -- Sort them, case-insensitively -- table.sort(ret, function (a, b) return a:path():lower() < b:path():lower() end) Config:set("maildir.max", #ret) return ret end -- -- Return the appropriate set of messages: -- -- 1. Get all messages. -- -- 2. Apply our index.limit. -- -- 3. Sort them. -- function get_messages () -- -- If we have a cached selection then we'll return it -- if global_msgs then return global_msgs end global_msgs = {} -- -- Otherwise fetch all the current messages. -- local msgs = Global:current_messages() -- -- How many steps do we expect to update for our progress-bar? -- local steps = math.floor(#msgs / Screen:width()) if steps == 0 then steps = 1 end -- -- Now apply any limit which should be present. -- -- Valid limits are: -- -- All -> All messages. -- New -> All messages which are unread. -- Today -> Show messages arrived today. -- "pattern" -> All messages matching the given pattern. -- local limit = Config.get_with_default("index.limit", "all") if limit == "all" then -- -- "All" -- -- This could be simplified to the following: -- global_msgs = msgs -- for i, o in ipairs(msgs) do -- Bump our progress-bar if math.fmod(i, steps) then Progress:show_percent(i, #msgs) end table.insert(global_msgs, o) end elseif limit == "new" then -- -- "New" -- for i, o in ipairs(msgs) do -- Bump our progress-bar if math.fmod(i, steps) then Progress:show_percent(i, #msgs) end -- If the message is new add it. if o:is_new() then table.insert(global_msgs, o) end end elseif limit == "attach" then -- -- Messages with attachments. -- for i, o in ipairs(msgs) do -- Bump our progress-bar if math.fmod(i, steps) then Progress:show_percent(i, #msgs) end -- If there are attachments add the message. if Message.count_attachments(o) > 0 then table.insert(global_msgs, o) end end elseif limit == "today" then -- -- "Today" -- local time = os.time() local today = time - (60 * 60 * 24) for i, o in ipairs(msgs) do -- Bump our progress-bar if math.fmod(i, steps) then Progress:show_percent(i, #msgs) end -- get the creation-date of the message local ctime = o:to_ctime() -- if it was within the past 24 hours then add it if ctime > today then table.insert(global_msgs, o) end end else -- -- "Pattern" -- for i, o in ipairs(msgs) do -- Bump our progress-bar if math.fmod(i, steps) then Progress:show_percent(i, #msgs) end local fmt = o:format() if string.find(fmt, limit) then table.insert(global_msgs, o) end end end -- -- Sort and return the set -- global_msgs = sort_messages(global_msgs) return global_msgs end -- -- Get the `parts` of a message as a table, handling all sub-parts too. -- -- This uses the nested function, insert_parts, which will recursively -- call itself to handle any children-parts. -- -- This function is used to create the display in attachment-mode, -- as well as for viewing/saving the various MIME-parts. -- function mimeparts2table (msg) local t = {} -- -- Nested function. -- -- Insert each part into the specified table, along with -- the children (recursively). -- function insert_parts (tbl, parts) for i, o in ipairs(parts) do -- Count the existing parts. local c = #tbl -- Build up the entry of each part local tmp = { count = c, filename = o:filename(), object = o, size = o:size(), type = o:type():lower(), } -- Insert the entry. table.insert(tbl, tmp) -- Now add the children of this part. insert_parts(tbl, o:children()) end end -- -- Call our (nested) function with the top-level parts of the -- message. In turn this will result in expansion. -- insert_parts(t, msg:parts()) return t end -- -- Simple utility function to test if the given message is -- new, or unread. -- function Message:is_new () local flags = self:flags() -- If it has the [N]ew-flag then it is new. if string.find(flags, "N") then return true end -- If it has the [S]een-flag then it is not new. if not string.find(flags, "S") then return true end return false end -- -- Return a suitable signature to use for the outgoing mail. -- -- This is based on the domain of the senders' email-address. -- -- ~/.signature will be used if nothing else is found. -- function Message.generate_signature () -- -- The sender of our mail. -- local sender = Config:get "global.sender" -- -- Get the home directory, if this fails we'll return an empty sig. -- home = os.getenv "HOME" if home == nil or home == "" then return "" end -- strip anything except the address, by looking between: <> -- i.e. "Steve Kemp" becomes steve@example.com sender = string.match(sender, "<(.*)>") or sender -- get the domain, lowercase it. domain = string.sub(sender, string.find(sender, "@") + 1) domain = string.lower(domain) -- Look for the domain-file ~/.sigs/$domain file = home .. "/.sigs/" .. domain if File:exists(file) then return (File.contents(file)) end -- -- Fallback to ~/.signature if present -- if File:exists(home .. "/.signature") then return (File.contents(home .. "/.signature")) end return "" end -- -- Compose a new message. -- function Message.compose (to, subject) -- Get some details local to = to or Screen:get_address "To:" if to == nil or to == "" then return end local subject = subject or Screen:get_line "Subject:" if subject == nil or subject == "" then subject = "No subject" end -- Get a temporary file, and opening it for writing local tmp = os.tmpname() local file = assert(io.open(tmp, "w")) -- -- Populate the header variables -- local from = Config:get "global.sender" local msgid = Message:generate_message_id() local date = os.date "%a, %d %b %Y %H:%M:%S %z" -- Write out a header header = [[To: ${to} From: ${from} Subject: ${subject} Message-ID: ${msgid} Date: ${date} Content-Type: text/plain; charset="utf-8" ${sig} ]] file:write(string.interp(header, { to = to, from = from, subject = subject, msgid = msgid, date = date, sig = Message.generate_signature(), })) file:close() -- -- This is a table of the attachment filenames the user is -- going to add to the outgoing mail. -- attachments = {} local run = true -- Open the editor Screen:execute(Config:get "global.editor" .. " " .. tmp) -- GPG options local encrypt = "" if GPG ~= nil then -- Get the default options encrypt = GPG.defaults(to) end while run do -- Once the editor quits ask for an action Screen:clear() local a = Screen:prompt("Send mail : (y)es, (n)o, re-(e)dit, (g)pg, or (a)dd an attachment?", "yYnNeEaAgG") -- -- GPG options. -- if (a == "g") or (a == "G") then -- Is GPG enabled? if GPG == nil then warning_msg "GPG support disabled!" else encrypt = GPG.prompt(encrypt) end end if (a == "e") or (a == "E") then -- Re-edit. Screen:execute(Config:get "global.editor" .. " " .. tmp) end if (a == "y") or (a == "Y") then -- If the user has added attachments then process them if #attachments > 0 then amsg = Message.new(tmp) amsg:add_attachments(attachments) end -- -- If the user has encryption options then do the necessary -- if encrypt ~= "" then tmp = GPG.replace_mail(tmp, encrypt, to) end -- Allow transformations to occur before sending the message. if type(on_message_send) == "function" then on_message_send(tmp) end -- Send the mail. os.execute(Config:get "global.mailer" .. " < " .. tmp) info_msg "Message sent" -- Allow transformations to occur after sending the message. if type(on_message_sent) == "function" then on_message_sent(tmp) end -- -- Now we need to save a copy of the outgoing message. -- Message.archive_outgoing(tmp) run = false end if (a == 'n') or (a == 'N') then -- Abort info_msg "Sending aborted!" run = false end if (a == 'a') or (a == 'A') then -- Add attachment path = Screen:get_path "Attachment path:" if File:exists(path) then table.insert(attachments, path) end end end -- -- Remove the temporary-file -- if File:exists(tmp) then os.remove(tmp) end end -- -- Reply to the current message. -- -- This literally just replies to the sender of the message, -- any CC'd addresses are dropped. -- function Message.reply () -- `False` means don't include the CC's Message.send_reply(false) end -- -- Reply to the current message, keeping all CC'd addresses. -- function Message.reply_all () -- `True` means keep the CC's. Message.send_reply(true) end -- -- Reply to the message at the point, either keeping -- or dropping CC'd addresses -- function Message.send_reply (include_cc) -- The message we're going to work on. local msg = Message.at_point() -- Failed to find a mesage? if not msg then error_msg "Failed to find message!" return end -- Get the text of a message. local txt = message_view(msg) -- Strip colours. txt = strip_colour(txt) -- Get a temporary file, and opening it for writing local tmp = os.tmpname() local file = assert(io.open(tmp, "w")) -- -- Work out who we should be replying to. -- local to = msg:header "Reply-To" if to == nil or to == "" then to = msg:header "From" end -- -- Are we going to change the recipient? -- if type(on_get_recipient) == "function" then -- -- Call the user-hook. If it returns a non-nil -- response that's the new recipient. -- local result = on_get_recipient(to, msg) if result then to = result end end -- -- Get the subject of the message. -- local subject = msg:header "Subject" if (subject == nil) or (subject == "") then subject = "No subject" end -- -- Will the user want to clean it? -- if type(on_clean_subject) == "function" then subject = on_clean_subject(subject) else -- -- Default to removing any "Re:"-prefix(es) from the subject. -- while string.find(subject, "^[rR][eE]:") do subject = string.gsub(subject, "^[rR][eE]:", "") subject = string.trim(subject) end end -- -- Get any CC'd addresses if we're supposed to do so. -- local cc = "" if include_cc then -- Get the recipient(s) + CCs as one string h_to = msg:header( "To" ) h_cc = msg:header("Cc" ) x = h_to .. "," .. h_cc -- Now we have a table of recipients recip = string.split( x , "," ) -- Remove dupes local hash = {} local res = {} for _,v in ipairs(recip) do -- strip leading/trailing whitespace v = string.trim(v) if (not hash[v]) then -- Ignore our self if ( v ~= Config:get "global.sender" ) then res[#res+1] = v hash[v] = true end end end -- Sort the table table.sort(res, function (a, b) return a:lower() < b:lower() end) -- Now we can join them back via "," cc = "" for _,v in ipairs(res) do cc = cc .. v .. ", " end -- Delete the trailing comma. cc = (cc:gsub(", $", "")) end -- Add prefix to the subject. subject = "Re: " .. subject -- Write out a header header = [[To: ${to} Cc: ${cc} From: ${from} Subject: ${subject} Date: ${date} Message-ID: ${msgid} In-Reply-To: ${source_id} References: ${references} Content-Type: text/plain; charset="utf-8" ]] -- -- Get the Message-ID of the message we're replying to. -- -- This should be but I've seen some horrors. -- local src_id = msg:header "Message-ID" local references = msg:header "References" or "" if src_id then src_id = string.match(src_id, "(<.*>)") or src_id -- Only referencing the single message if references == "" then references = src_id else -- Append the source to the existing references. references = references .. " " .. src_id end end file:write(string.interp(header, { to = to, cc = cc, from = Config:get "global.sender", subject = subject, source_id = src_id, references = references, msgid = Message:generate_message_id(), date = os.date "%a, %d %b %Y %H:%M:%S %z", })) -- -- Header to the message -- local reply = Config.get_with_default("global.reply-header", "") -- -- If one is set then use it. -- -- The value can use any value from the message, e.g: -- -- Config:set( "global.reply-header", "${from} wrote:" ) -- if reply ~= "" then -- The headers from the messages will be available local headers = msg:headers() -- -- Populate "email" and "name" is distinct values, based -- upon the from: header. -- headers['email'] = string.match(headers['from'], "<(.*)>") or h['from'] headers['name'] = string.match(headers['from'], "(.*)<(.*)>") or h['from'] if headers['email'] then headers['email'] = string.trim(headers['email']) end if headers['name'] then headers['name'] = string.trim(headers['name']) end -- Format the header appropriately. local line = string.interp(reply, headers) file:write(line .. "\n") end -- -- We don't want to include the header in the massage, -- just the text of the body. -- local in_header = true for i, l in ipairs(txt) do if in_header then if l == "" then in_header = false end else file:write("> " .. l .. "\n") end end -- Append a signature file:write "\n" file:write(Message.generate_signature()) file:close() -- -- This is a table of the attachment filenames the user is -- going to add to the outgoing mail. -- attachments = {} local run = true -- Open the editor Screen:execute(Config:get "global.editor" .. " " .. tmp) -- GPG options local encrypt = "" if GPG ~= nil then encrypt = GPG.defaults(to) end while run do -- Once the editor quits ask for an action Screen:clear() local a = Screen:prompt("Send mail : (y)es, (n)o, re-(e)dit, (g)pg, or (a)dd an attachment?", "yYnNeEaAgG") -- -- GPG options. -- if (a == "g") or (a == "G") then -- Is GPG enabled? if GPG == nil then warning_msg "GPG support disabled!" else encrypt = GPG.prompt(encrypt) end end if (a == "e") or (a == "E") then -- Re-edit. Screen:execute(Config:get "global.editor" .. " " .. tmp) end if (a == "y") or (a == "Y") then -- If the user has added attachments then process them if #attachments > 0 then amsg = Message.new(tmp) amsg:add_attachments(attachments) end -- -- If the user has encryption options then do the necessary -- if encrypt ~= "" then tmp = GPG.replace_mail(tmp, encrypt, to) end -- Allow transformations to occur before sending the message. if type(on_message_send) == "function" then on_message_send(tmp) end -- Send the mail. os.execute(Config:get "global.mailer" .. " < " .. tmp) info_msg("Reply sent to " .. to) -- Allow transformations to occur after sending the message. if type(on_message_sent) == "function" then on_message_sent(tmp) end -- -- Since we've sent the message we need to add the "(R)eplied" -- flag on the source message. -- local cf = msg:flags() if not string.find(cf, "R") then cf = cf .. "R" msg:flags(cf) end -- -- Now we need to save a copy of the outgoing message. -- Message.archive_outgoing(tmp) run = false end if (a == "n") or (a == "N") then -- Abort info_msg "Reply aborted!" run = false end if (a == 'a') or (a == 'A') then -- Add attachment path = Screen:get_path "Attachment path:" if File:exists(path) then table.insert(attachments, path) end end end -- -- Remove the temporary-file -- if File:exists(tmp) then os.remove(tmp) end end -- -- Save a message -- function Message.archive_outgoing (msg_path) -- -- Save globally. -- local sent = Config:get "global.sent-mail" if sent then local msent = Maildir.new(sent) local tmp_msg = Message.new(msg_path) msent:save_message(tmp_msg) end end -- -- Delete the current message. -- function Message.delete (self) -- The Message we want to delete. local msg = self or Message.at_point() -- If there is no message we have nothing to do. if not msg then error_msg "Failed to find message!" return end -- -- WARNING: index.current is 0 based! -- local msg_index = Config.get_with_default("index.current", 0) + 1 -- Delete the message. msg:unlink() -- Delete the message from the cache. table.remove(global_msgs, msg_index) local mode = Config:get "global.mode" if mode == "message" then -- select next message if msg_index <= (#global_msgs) then Global:select_message(global_msgs[msg_index]) end elseif mode == "index" then -- if deleting the last message move the selection down. if msg_index >= (#global_msgs) then Config:set("index.current", #global_msgs - 1) end end end -- --- Trash the current Message -- -- Move the current message to the Trash maildir before deleting it -- function Message.trash(self) -- The Message we want to delete. local msg = self or Message.at_point() -- If there is no message we have nothing to do. if not msg then error_msg "Failed to find message!" return end -- Move message to trash. local trash = Config.get_with_default("global.trash-mail", nil) if trash and trash ~= Global:current_maildir():path() then Message.save(trash) end -- Delete the original message. msg:delete() end -- -- Forward the current message -- function Message.forward () -- The message we're going to work on. local msg = Message.at_point() -- Failed to find a message? if not msg then error_msg "Failed to find message!" return end local to = Screen:get_address "Forward to:" if to == nil or to == "" then return end -- Get the text of a message. local txt = message_view(msg) -- Strip colours. txt = strip_colour(txt) -- Write it out to to a temporary file local tmp = os.tmpname() local file = assert(io.open(tmp, "w")) -- Write out a header -- Write out a header header = [[To: ${to} From: ${from} Subject: Fwd: ${subject} Message-ID: ${msgid} Date: ${date} Content-Type: text/plain; charset="utf-8" Begin forwarded message. ]] local from = Config:get "global.sender" local subject = msg:header "Subject" file:write(string.interp(header, { from = from, to = to, subject = subject, msgid = Message:generate_message_id(), date = os.date "%a, %d %b %Y %H:%M:%S %z", })) for i, l in ipairs(txt) do file:write("> " .. l .. "\n") end -- Append the signature file:write "\n" file:write(Message.generate_signature()) file:close() -- -- This is a table of the attachment filenames the user is -- going to add to the outgoing mail. -- attachments = {} local run = true -- Open the editor Screen:execute(Config:get "global.editor" .. " " .. tmp) -- GPG options local encrypt = "" if GPG ~= nil then encrypt = GPG.defaults(to) end while run do -- Once the editor quits ask for an action Screen:clear() local a = Screen:prompt("Forward mail : (y)es, (n)o, re-(e)dit, (g)pg, or (a)dd an attachment?", "yYnNeEaAgG") -- -- GPG options. -- if (a == "g") or (a == "G") then -- Is GPG enabled? if GPG == nil then warning_msg "GPG support disabled!" else encrypt = GPG.prompt(encrypt) end end if (a == "e") or (a == "E") then -- Re-edit. Screen:execute(Config:get "global.editor" .. " " .. tmp) end if (a == "y") or (a == "Y") then -- If the user has added attachments then process them if #attachments > 0 then amsg = Message.new(tmp) amsg:add_attachments(attachments) end -- -- If the user has encryption options then do the necessary -- if encrypt ~= "" then tmp = GPG.replace_mail(tmp, encrypt, to) end -- Allow transformations to occur before sending the message. if type(on_message_send) == "function" then on_message_send(tmp) end -- Send the mail. os.execute(Config:get "global.mailer" .. " < " .. tmp) info_msg "Message forwarded" -- Allow transformations to occur after sending the message. if type(on_message_sent) == "function" then on_message_sent(tmp) end -- -- Now we need to save a copy of the outgoing message. -- Message.archive_outgoing(tmp) run = false end if (a == "n") or (a == "N") then -- Abort info_msg "Forwarding aborted!" run = false end if (a == 'a') or (a == 'A') then -- Add attachment path = Screen:get_path "Attachment path:" if File:exists(path) then table.insert(attachments, path) end end end -- -- Remove the temporary-file -- if File:exists(tmp) then os.remove(tmp) end end -- -- Toggle the new/old status of a message. -- function Message.toggle () -- The message we're going to work on. local msg = Message.at_point() if not msg then error_msg "Failed to find a message" return end -- -- Toggle the status. -- if msg:is_new() then msg:mark_read() else msg:mark_unread() end end -- -- Save a copy of the current message elsewhere. -- function Message.save (dest) -- The message we're going to work on. local msg = Message.at_point() if not msg then error_msg "Failed to find a message" return end -- -- Are we using IMAP? -- local imap = true -- -- Get the IMAP configuration-values -- local i_s = Config.get_with_default("imap.server", "") local i_u = Config.get_with_default("imap.username", "") local i_p = Config.get_with_default("imap.password", "") -- -- If any of them is empty then we're not using IMAP -- if (i_s == "") or (i_u == "") or (i_p == "") then imap = false end -- -- Is there a parameter ? -- if not dest then -- -- Prompt differently based on which storage is in-use. -- if imap then -- -- Prompt for the IMAP folder. -- dest = Screen:get_line "Copy to IMAP folder:" else -- Get the default destination -- local prefix = Config:get "maildir.prefix" local location = os.getenv "HOME" -- -- If we have a prefix which is a string then use it. -- if type(prefix) == "string" then location = prefix elseif type(prefix) == "table" then -- -- If we have multiple values set then use the first. -- location = prefix[1] end -- -- Prompt for destination -- dest = Screen:get_line("Copy to local maildir:", location) -- -- Does the destination exist, as a maildir? -- if not Directory:is_maildir(dest) then -- -- Directory doesn't exist - see if we should create it -- local answer = Screen:prompt("Directory doesn't exist. Create it? [Y/N]", "yYNn") if (answer == 'y') or (answer == 'Y') then Maildir:create(dest) else info_msg "Refusing to save to missing-maildir" return end end end -- -- Nothing entered? Abort. -- if dest == nil or dest == "" then info_msg "Copy aborted" return end end -- -- Create a new helper for the destination -- local dest_f = Maildir.new(dest) -- -- Save the message-there. local ret = dest_f:save_message(msg) if ret then info_msg("Message copied to " .. dest) else error_msg "Message save failed." end end -- -- Utility function to open the Maildir with the given name. -- -- This matches against the path, literally, and the first match wins. -- -- A literal match means we don't use either PCRE regular expressions -- or Lua regular expressions. That means you don't need to escape -- any special characters. -- function Maildir.select (desired, change_mode_too) -- Get the maildirs local folders = maildirs() -- For each one .. see if it matches for index, object in ipairs(folders) do local path = object:path() if string.ends(path, desired) then -- Save the header cache of the previous selected maildir if possible. Hcache.save() -- Select the maildir, to make it current. Global:select_maildir(object) -- Load the header cache of the new selected maildir. Hcache.load() -- And update the current selection. Config:set("maildir.current", index - 1) -- If we're supposed to change mode, do so. if change_mode_too then change_mode "index" -- Flush the cached message-list global_msgs = nil -- Invoke the per-user hook, if present. if type(on_folder_changed) == "function" then on_folder_changed(object) end end -- The first match wins, so we return after updating. return end end end -- -- This function saves the MIME part under the cursor to the named path. -- -- If the function succeeds it returns the MIME object, otherwise it -- returns false. -- function write_mime_part( path ) -- -- Get the currently highlighted attachment-offset -- local mode = Config:get "global.mode" local cur = Config.get_with_default(mode .. ".current", 0) -- -- Get the current message, and then the parts. -- local msg = Global:current_message() if not msg then warning_msg "There is no currently selected message!" return false end -- Parse the MIME parts into an ordered list. local out = mimeparts2table(msg) -- Get the part we should view local found = out[cur + 1] -- If we found the part. if found then -- Open the file for writing local file = assert(io.open(path, "w")) -- save the content there file:write(found['object']:content()) file:close() return found else warning_msg "Failed to find MIME-part!" return false end end -- -- This function is called by pressing `s` in attachment-mode -- function save_mime_part () -- Prompt for local-path local output = Screen:get_path("Save to:") -- Ensure we got a path. if output == nil or output == "" then info_msg "Attachment saving aborted!" return end -- Write out the part if ( write_mime_part( output ) ) then info_msg("Wrote attachment to " .. output) else warning_msg( "Failed to write attachment" ) end end -- -- This function is called by pressing `SPACE`/`ENTER` in attachment-mode -- function view_mime_part (cmd) -- -- Generate a temporary file to write the attachment to -- local tmp = os.tmpname() -- -- Write it out and test for success -- local obj = write_mime_part( tmp ) if ( obj == false ) then warning_msg( "Failed to write out object") return end -- Get the MIME-type of the attachment local mime = obj['type']:lower() -- Lookup the viewer to use, if not specified. if (not cmd) or (cmd == "") then cmd = get_mime_viewer(mime) end -- Replace "%s" with the filename, and run the command cmd = string.gsub(cmd, "%%s", tmp) -- Execute it Screen:execute(cmd) -- Remove the file os.remove(tmp) end -- -- Remove a colour-prefix from the given string. -- -- Our drawing code allows lines to be different coloured based upon -- a prefix. So for example the text "$[RED]I like Helsinki" would be -- drawn as "I like Helsinki", in the colour red. -- -- This function removes any existing colour prefixes. -- function strip_colour (input) -- -- If we've been given a table of input then -- process each line. -- if type(input) == "table" then ret_val = {} for o, p in ipairs(input) do while string.find(p, "^$[^]]+]") do p = string.gsub(p, "^$[^]]+]", "") end table.insert(ret_val, p) end return ret_val end -- -- Handling a single string -- while string.find(input, "^$[^]]+]") do input = string.gsub(input, "^$[^]]+]", "") end return input end -- -- This function takes a table of lines, and will iterate over -- every line, updating the strings if we find a match on the -- regular expressions contained in the colour-table -- function add_colours (lines, mode) -- -- Get the table of colours for the mode -- if it doesn't exist then abort. -- local colour_table = _G['colour_table'] if not colour_table then return lines end if not colour_table[mode] then return lines end local ret = {} -- -- Iterate over the input-table -- for index, line in ipairs(lines) do -- -- Iterate over the colours -- for regexp, colour in pairs(colour_table[mode]) do if string.match(line, regexp) then line = colour_line(line, colour) end end table.insert(ret, line) end return ret end -- -- This function returns the output which is displayed in attachment-mode -- -- Attachment mode is entered by pressing `A` when a message is open. -- function attachment_view () local result = {} -- -- Get the parts from within the current message -- local msg = Global:current_message() if not msg then return { "No message selected!", } end -- -- Parse the mime-types to a table -- local out = mimeparts2table(msg) -- -- Fetch the format-string -- local format = Config.get_with_default("attachment.format", "[${4|number}] ${6|size} - ${25|type} - ${filename}") for i, o in ipairs(out) do local output = (string.interp(format, { number = o["count"] + 1, size = o["size"] or "", type = o["type"] or "", filename = o["filename"] or "", })) table.insert(result, output) end result = add_colours(result, 'attachment') return result end -- -- This function formats a single message for display in index-mode, -- it is called by the `index_view()` function defined next. -- function Message:format (thread_indent, index) local path = self:path() local time = self:mtime() -- -- Cache key is conditional on the sort-method, -- the index-format, and the path of the message. -- -- That means changing any of them will flush the cached value. -- local ckey = path .. "message:" .. Config.get_with_default("index.sort", "index.sort") .. Config.get_with_default("index.format", "index.format") .. time -- Do we have this cached? If so return it if cache:get(ckey) then return (cache:get(ckey)) end if not thread_indent then thread_indent = "" end -- -- Basics from the message -- local flags = self:flags() local subject = self:header "Subject" local sender = self:header "From" local recipient = self:header "To" local date = self:header "Date" local id = self:header "Message-ID" -- -- Try to parse out sender into "email" + "name". -- local email = string.match(sender, "<(.*)>") or sender local name = string.gsub(sender, "<(.*)>", "") if name:len() < 1 then name = sender end -- -- Again for recipient. -- local recipient_email = string.match(recipient, "<(.*)>") or recipient local recipient_name = string.gsub(recipient, "<(.*)>", "") if recipient_name:len() < 1 then recipient_name = recipient_name end -- -- Compatibility updates -- local sender_email = email local sender_name = name -- -- The user might have a filter-function to cleanup -- the name of the sender. -- -- (This is mostly to handle transforming a string -- such as "Steve Kemp (via Twitter)" into "Steve Kemp") -- if type(on_clean_name) == "function" then name = on_clean_name(name) end -- -- Get the message-flags - these flags are informational, and -- unrelated to the flags a message might have. -- -- A => Message has attachments. -- S => Message is signed. -- local m_flags = "" local parts = mimeparts2table(self) local a_count = 0 for i, o in ipairs(parts) do if o['type'] == "text/x-gpg-output" then m_flags = m_flags .. "S" end if o['filename'] ~= nil and o['filename'] ~= "" then a_count = a_count + 1 end end if a_count > 0 then m_flags = "A" .. m_flags end -- -- The format-string we use for display. -- local format = Config.get_with_default("index.format", "[${4|flags}] ${2|message_flags} - ${20|sender} - ${indent}${subject}") -- -- Format this message for display -- local output = (string.interp(format, { flags = flags, message_flags = m_flags, sender = sender, sender_name = sender_name, sender_email = sender_email, email = email, -- depreciated name = name, -- depreciated indent = thread_indent, subject = subject, number = index, date = date, id = id, recipient = recipient, recipient_name = recipient_name, recipient_email = recipient_email, })) -- -- If the message is unread then show it in the "unread" colour -- if self:is_new() then output = "$[UNREAD]" .. output end -- Update the cache. cache:set(ckey, output) return output end -- -- This function displays the screen when in `index`-mode. -- -- It fetches the list of current messages, and calls `Message:format()` -- on each one. -- -- As an optimization, primarily useful for large Maildirs, or when -- using IMAP we can instead elect to only format the visible messages -- if the setting `index.fast` is enabled. -- function index_view () local result = {} -- Get the available messages. local messages = get_messages() if (messages == nil) or (#messages == 0) then Screen:draw(10, 10, "There are no visible messages.") return result end -- Get the current offset local mode = Config:get "global.mode" local cur = tonumber(Config.get_with_default("index.current", 0)) -- -- If the current line is bigger than the count of messages -- then something has gone wrong. -- if cur >= #messages then cur = #messages - 1 end -- Find the height of the screen. local height = Screen:height() -- The minimum message number we're going to format local min = cur - height if min < 0 then min = 0 end -- The maximum message-number we're going to format. local max = cur + height -- Are we optimizing? local fast = Config.get_with_default("index.fast", 0) for offset, object in ipairs(messages) do local str = "INVISIBLE" -- If optimizing if fast ~= 0 then -- -- Only show the message if it will fit on the screen -- if (offset >= min) and (offset < max) then str = object:format(threads_indentation[object], offset) else str = "INVISIBLE - Outside the viewport!" end else -- Else format all entries str = object:format(threads_indentation[object], offset) end table.insert(result, str) end -- -- Update the colours -- result = add_colours(result, 'index') return result end -- -- This function shows our keybindings, both globally and for each mode. -- function keybinding_view () -- -- The output we'll display. -- output = {} -- -- If we have globals -- local globals = keymap['global'] local jump_range = {} if globals then table.insert(output, "$[RED]Global Keybindings") table.insert(output, "\n") for i, o in pairs(table.sorted_keys(globals)) do -- Skip global bindings for "jump(N)", because there -- will be approximately 1000 of them! if not string.match(globals[o], "^jump") then local entry = string.format(" %10s -> %s", o, globals[o]) table.insert(output, entry) else local line_number = tonumber(globals[o]:match "%d+") if #jump_range == 0 then jump_range = { line_number, line_number, } elseif line_number > jump_range[2] then jump_range[2] = line_number elseif line_number < jump_range[1] then jump_range[1] = line_number end end end if #jump_range == 2 then jump_range = string.format("%d-%d", jump_range[1], jump_range[2]) local jump_entry = string.format(" %10s -> jump(N)", jump_range) table.insert(output, jump_entry) end end for i, mode in pairs(table.sorted_keys(keymap)) do if mode and (mode ~= "global") then local bind = keymap[mode] table.insert(output, "\n\n") table.insert(output, "$[BLUE]Keybindings for " .. mode .. "-mode") table.insert(output, "\n") if table.size(bind) > 0 then for i, o in pairs(table.sorted_keys(bind)) do local entry = string.format(" %10s -> %s", o, bind[o]) table.insert(output, entry) end else table.insert(output, " NONE.") end end end output = add_colours(output, 'keybindings') return output end -- -- Functions related to life-view -- do -- If we don't have life enabled, then create some. if not life then life = Life.new(Screen:width(), Screen:height()) life:random() end -- Don't output anything for display. The C++ code will then -- call its `on_idle()` function to display things directly. function life_view () output = {} return output end end -- -- This function returns the output which is displayed in `lua`-mode. -- function lua_view () -- -- The output we'll display. -- output = {} -- -- Show the version of Lua/LuaJit. -- local lver = "" if jit then lver = jit.version else lver = _VERSION end lver = "$[RED]<$[GREEN|BOLD]" .. lver .. "$[RED]>" -- -- Show the version of lumail & lua. -- table.insert(output, "$[RED]This $[GREEN]is $[YELLOW|UNDERLINE|BOLD]Lumail$[CYAN|NORMAL] version $[BLUE|BOLD]" .. Config:get "global.version" .. " " .. lver) table.insert(output, "") table.insert(output, "$[RED]This RED - and not $[#GREEN]!") table.insert(output, "") -- -- Test UTF-8 -- table.insert(output, "This line is for UTF-8 testing $[RED]«ÜßÄ$€»$[CYAN]‹ÅåÄäÖö›") table.insert(output, "") table.insert(output, "This line is for testing line-wrapping: 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 ") table.insert(output, "") -- -- Now show the current maildir, message, etc. -- local mode = Config:get "global.mode" table.insert(output, "The current mode is") table.insert(output, "$[YELLOW]\t" .. mode) table.insert(output, "Valid modes are:") for i, o in ipairs(Global:modes()) do table.insert(output, "\t" .. o) end table.insert(output, "The currently selected maildir is") local md = Global:current_maildir() if md then table.insert(output, "$[RED]\t" .. md:path()) else table.insert(output, "$[RED]\tUNSET") end table.insert(output, "The currently selected message is") local msg = Global:current_message() if msg then table.insert(output, "$[RED]\t" .. msg:path()) else table.insert(output, "$[RED]\tUNSET") end -- -- Now add all the current keys -- table.insert(output, "") table.insert(output, "The following configuration-values have been set:") for k, v in ipairs(Config:keys()) do val = Config:get(v) table.insert(output, "\t" .. v .. " <" .. type(val) .. ">") if type(val) == "string" then table.insert(output, "$[YELLOW]\t\t" .. val) end if type(val) == "number" then table.insert(output, "$[YELLOW]\t\t" .. val) end if type(val) == "table" then for o, p in ipairs(val) do table.insert(output, "$[YELLOW]\t\t" .. p) end end end -- -- And return the text. -- return output end -- -- Show all the output from the panel. -- -- This is more useful than it sounds because the panel is not scrollable -- nor can you add line-wrapping, but the panel-mode allows both those -- things. -- function panel_view () local result = Panel:text() result = add_colours(result, 'panel') return result end -- -- Create a maildir if it doesn't exist -- function Maildir:create (path) Directory:mkdir(path) Directory:mkdir(path .. "/cur") Directory:mkdir(path .. "/new") Directory:mkdir(path .. "/tmp") end -- -- This method returns the text which is displayed when a maildir is -- to be show in maildir-mode. -- -- The actual output of maildir-mode is generated by `maildir_view` -- which is defined below. -- function Maildir:format (index) local path = self:path() local time = self:mtime() local trunc = Config.get_with_default("maildir.truncate", 0) local src = "maildir" -- -- If we're a remote IMAP Maildir then we cache that based on the -- server-name. -- -- This allows the user to switch IMAP-servers/accounts without -- any problem if self:is_imap() then src = "imap" src = src .. Config.get_with_default("imap.server", "localhost") src = src .. Config.get_with_default("imap.username", "username") end -- -- The cache-key is based upon -- -- 1. The type of folder (maildir vs. IMAP) -- 1.5. The value of `maildir.truncate`. -- 2. The path to the folder. -- 3. The format-string. -- 4. The mtime of the directory. -- -- This means if any of them change we flush the cache -- local ckey = path .. "maildir:" .. trunc .. src .. time .. Config.get_with_default("maildir.format", "maildir.format") -- Do we have this cached? If so return it if cache:get(ckey) then return (cache:get(ckey)) end local total = self:total_messages() local unread = self:unread_messages() -- -- Path might be truncated, via "p". -- if trunc ~= 0 then -- -- Work out which prefix the maildir is beneath, and strip it -- local prefix = Config:get "maildir.prefix" -- -- Simple case first - if there is only a single maildir prefix -- then we just truncate. -- if type(prefix) == "string" then path = string.sub(path, prefix:len() + 2) if path == nil or path == "" then path = "INBOX" end end -- -- Otherwise we need to test each one. -- if type(prefix) == "table" then for index, pre in ipairs(prefix) do local poss = string.sub(path, 0, pre:len()) if poss == pre then path = string.sub(path, pre:len() + 2) if path == nil or path == "" then path = "INBOX" end end end end end -- -- The format-string we use for display. -- local format = Config.get_with_default("maildir.format", "[${05|unread}/${05|total}] - ${path}") -- -- Format this maildir for display -- local output = (string.interp(format, { total = total, unread = unread, number = index, path = path, })) -- -- If there are unread messages then show it in the unread-colour. -- if unread > 0 then output = "$[UNREAD]" .. output end -- update the cache cache:set(ckey, output) return output end -- -- This method returns the text which is displayed in maildir-view -- -- It retrieves the list of Maildirs, and calls `Maildir:format` on -- each one. -- function maildir_view () local result = {} -- Get the maildirs local folders = maildirs() -- If we had an old selection in-place then try to -- preserve that. if prev_maildir then info_msg("Trying to select " .. prev_maildir) Maildir.select(prev_maildir, false) prev_maildir = nil end if (folders == nil) or (#folders == 0) then Screen:draw(10, 10, "There are no visible folders.") return result end -- For each one add the output for index, object in ipairs(folders) do local str = object:format(index) table.insert(result, str) end -- -- Update the colours -- result = add_colours(result, 'maildir') return result end -- -- Given a line of text escape any colour-definitions -- -- The formatting of colours that lumail uses is very -- simple, the following is an example: -- -- $[RED]This is red $[GREEN]This is green. -- -- To display this text literally we'd add "#" to the name -- of the colour, so for example you might have this: -- -- $[#PINK]This is actually white - the "#" escapes it. -- -- To escape the value of any formatting that might be in the -- message body we thus turn this: -- -- $[GREEN]Green $[Blue]Blue -- -- to this: -- -- $[#GREEN]Green $[#BLUE]Blue -- -- function escape_message_colours (txt) txt = string.gsub(txt, "$%[([a-zA-Z|])", "$[#%1") return txt end -- -- This method returns the text which is displayed in message-mode. -- -- First of all the current-message is retrieved, then that is -- formatted into an array of lines which are displayed to the user. -- -- The scrolling is handled on the C++ side. -- function message_view (msg) -- -- If we're called in `message`-mode then we'll not have an -- argument, so we need to find the message. -- if not msg then msg = Global:current_message() end if not msg then return { "No message selected!", } end -- -- Change the message to being read, if it is new. -- if msg:is_new() then msg:mark_read() end local output = "" -- -- We will either show "some" headers, or all headers. -- -- The user may choose to view all headers by pressing `H`, -- which will invoke: -- -- Config.toggle( "message.headers" ); -- local full_headers = Config.get_with_default("message.headers", 0) -- -- The headers we want to see if not full. -- headers = { "To", "From", "Cc", "Subject", "Date", } if full_headers ~= 0 then info_msg "Showing all headers" -- -- Show all headers -- local all_headers = msg:headers() local sorted_headers = {} for n, v in pairs(all_headers) do table.insert(sorted_headers, n) end table.sort(sorted_headers) for index, name in pairs(sorted_headers) do local value = all_headers[name] if value then output = output .. name .. ": " .. value .. "\n" end end else -- -- Just show the small list. -- for i, header in ipairs(headers) do value = msg:header(header) or "[unset]" if value then output = output .. header .. ": " .. value .. "\n" end end end -- -- Does the message contain attachments? -- -- If so add a pseudo-header to show the count of same. -- local a_count = Message.count_attachments(msg) if a_count > 0 then output = output .. "$[BLUE]Attachments: " .. a_count .. "\n" end -- -- Is there any GPG-signature? -- local parts = mimeparts2table(msg) for i, o in ipairs(parts) do if o['type'] == "text/x-gpg-output" then local gpg = o['object']:content() for ii, oo in pairs(string.to_table(gpg)) do output = output .. "$[RED]" .. oo .. "\n" end end end -- -- Add newline after the header-area. -- output = output .. "\n" -- -- Now we're going to build up the body of the mail. -- -- We'll either show the first or ALL parts of the appropriate -- type, and we prefer "text/plain" to "text/html", but will -- display the latter if that is all we have. -- -- We'll store the parts in a table keyed on the MIME-type -- local content = {} -- -- Are we displaying the first, or all parts? -- local all = Config.get_with_default("message.all_parts", 0) -- -- If we're going to show all parts do we append new parts -- or prepend them? -- local cat = Config.get_with_default("message.prepend", 0) -- -- Process each MIME-part of the message to find the body. -- local parts = mimeparts2table(msg) for i, part in ipairs(parts) do -- Content-Type local ct = part['type']:lower() -- Size local sz = part['size'] -- Content local con = part['object']:content() -- If this is a text/BLAH part, which is not empty. if (string.find(ct, "text/")) and (sz > 0) then -- If we're showing all parts .. if all == 1 then if content[ct] then -- There is existing content. if cat == 0 then -- Prepend the new part. content[ct] = con .. content[ct] else -- Append the new part. content[ct] = content[ct] .. con end else content[ct] = con end else -- Only store the content if we didn't find a part already. if content[ct] == nil then content[ct] = con end end end end -- -- Did we show the body? -- local txt = content["text/plain"] or content["text/html"] or "Failed to find suitable text/plain or text/html content." -- -- Escape the colours in the body -- txt = escape_message_colours(txt) -- -- Now add to the body. -- output = output .. txt -- -- The command output is now split into rows. -- local result = string.to_table(output) -- -- Update the colours -- result = add_colours(result, 'message') return result end -- -- Define some functions which are bound to keys, to move around, etc. -- ----------------------------------------------------------------------------- -- -- Read input, and evaluate it as lua. -- function read_eval () local txt = Screen:get_lua ":" if txt == nil or txt == "" then return end loadstring(txt)() end -- -- Read input, and evaluate it as a shell-command. -- function read_execute () local cmd = Screen:get_shell "!" if cmd == nil or cmd == "" then return end os.execute(cmd) end -- -- Allow navigation - Selection of a maildir, or message. -- function select () local mode = Config:get "global.mode" local cur = Config.get_with_default(mode .. ".current", 0) if mode == "maildir" then local folders = maildirs() local folder = folders[cur + 1] if folder == nil then error_msg "There is nothing to select!" return end -- -- Select the folder and flush the message-cache. -- Global:select_maildir(folder) global_msgs = nil -- -- Call the user-function, if it exists. -- if type(on_folder_changed) == "function" then on_folder_changed(folder) else -- -- Just show the folder we selected and the number of messages. -- local size = #get_messages() if size == 1 then info_msg("Selected " .. folder:path() .. " with 1 message.") else info_msg("Selected " .. folder:path() .. " with " .. size .. " messages.") end end -- Populate the header cache for this maildir Hcache.load() -- -- Change to the index-mode, so we can see the messages in -- the folder. -- change_mode "index" return end if mode == "index" then -- -- Get the messages -- local msgs = get_messages() -- -- Get the current offset. -- local msg = msgs[cur + 1] if msg == nil then info_msg "There is nothing to select!" return end -- -- Now select -- Global:select_message(msg) change_mode "message" -- -- Call the user-function, if it exists. -- if type(on_message_changed) == "function" then on_message_changed(msg) end return end end -- -- Jump to the first entry in the current-mode. -- function first () local mode = Config:get "global.mode" Config:set(mode .. ".current", 0) end -- -- Jump to the last entry in the current-mode. -- -- We could always set this to jump to the maximum -- line, but doing so would result in an empty display -- in the case of viewing a message, or viewing lua-mode. -- -- We handle that by scrolling until the last line is *visible* -- rather than selected. -- function last () local mode = Config:get "global.mode" local max = Config:get(mode .. ".max") -- -- Some modes we special case such that jumping to the end -- doesn't mean showing only the last line - but making the -- last line visible. -- if mode ~= "message" and mode ~= "lua" and mode ~= "panel" and mode ~= "keybinding" then Config:set(mode .. ".current", max - 1) return end -- -- For these two remaining modes we want to jump -- such that the end of the display is visible -- but not selected. -- -- Get the current offset. local cur = Config:get(mode .. ".current") -- Jump to the end of the display cur = max - Screen:height() -- Account for the panel if Panel:visible() then cur = cur + Panel:height() end -- Bound the values. if cur > max then cur = max end if cur < 0 then cur = 0 end -- And finally update them Config:set(mode .. ".current", cur) end -- -- Left/Right scrolling -- function left () local x = Config.get_with_default("global.horizontal", 0) if x > 0 then x = x - 1 end Config:set("global.horizontal", x) end -- -- Left/Right scrolling. -- function right () local x = Config.get_with_default("global.horizontal", 0) x = x + 1 Config:set("global.horizontal", x) end -- -- Allow searching for the next line containing a match. -- -- This function might need some care to understand: -- -- * The mail-client is a modal editor. -- * You're always in one mode, stored in "Config:get("global.mode")". -- * Each mode displays the output of the lua-function $mode_view() -- * This function returns a table of lines. -- * There are also "current-offset" and "max-size" variables for these tables. -- * We can use this to dynamically invoke the right mode, and iterate. -- -- do -- -- This is the value that the user entered previously, and is -- the default if nothing is entered again. -- local search_text = '' function find (offset) -- Get the search pattern. local pattern = Screen:get_line "/:" -- If the user didn't enter anything then use the previous -- input, if any. if pattern == nil or pattern == "" then pattern = search_text end -- Pattern is lower-cased to make searches case-insensitive. pattern = pattern:lower() -- Save for next time. search_text = pattern -- Get the global mode. local mode = Config:get "global.mode" -- Use that to get the lines we're currently displaying loadstring("out = " .. mode .. "_view()")() -- -- We know the current offset is stored in -- the variable $mode.current -- -- We know the maximum offset is stored in the -- variable $mode.max -- local cur = Config.get_with_default(mode .. ".current", 0) local max = Config:get(mode .. ".max") -- -- We'll keep track of how many times we've moved forward -- to avoid looping indefinitely. -- local count = -1 cur = cur + 1 -- -- Start searching from the current-position -- while count < max do cur = cur + offset if cur > max then cur = 1 end if cur < 1 then cur = max end -- Get the current entry local line = out[cur] if line then line = line:lower() if string.match(line, pattern) then Config:set(mode .. ".current", (cur - 1)) return end end count = count + 1 end warning_msg("No match found for $[WHITE|BOLD]" .. pattern) end end -- -- Scroll the current mode down - by manipulating the "current" offset -- for the current mode. -- -- Page is a boolean which means: -- -- false -> Move a single line -- true -> Move a single page. -- function next (page) local mode = Config:get "global.mode" local max = Config:get(mode .. ".max") local cur = Config.get_with_default(mode .. ".current", 0) local offset = 1 if page then if Panel:visible() then offset = Screen:height() - Panel:height() - 1 else offset = Screen:height() - 1 end end if cur + offset < (max - 1) then cur = cur + offset else cur = max - 1 end if cur >= max then cur = max end Config:set(mode .. ".current", cur) end -- -- Scroll the current mode up - by manipulating the "current" offset -- for the current mode. -- -- -- Page is a boolean which means: -- -- false -> Move a single line -- true -> Move a single page. -- function prev (page) local mode = Config:get "global.mode" local cur = Config.get_with_default(mode .. ".current", 0) local offset = 1 if page then if Panel:visible() then offset = Screen:height() - Panel:height() - 1 else offset = Screen:height() - 1 end end if cur - offset > 0 then cur = cur - offset else cur = 0 end Config:set(mode .. ".current", cur) end -- -- Jump to the given offset, in a mode-agnostic fashion. -- -- Although this only really makes sense in those modes where -- you have a highlight (i.e. non-simple modes). -- function jump (offset) local mode = Config:get "global.mode" local max = Config:get(mode .. ".max") if offset <= max then Config:set(mode .. ".current", offset - 1) end end -- -- Scroll to the next maildir containing unread messages, or message -- which is unread. -- function next_unread () -- -- This function only works in maildir or index modes. -- local mode = Config:get "global.mode" if (mode ~= "index") and (mode ~= "maildir") then return end -- -- We know the current offset is stored in `$mode.current`. -- -- We know the maximum offset is stored in `$mode.max`. -- local cur = Config.get_with_default(mode .. ".current", 0) local max = Config:get(mode .. ".max") -- -- We'll keep track of how many times we've moved forward -- to avoid looping indefinitely. -- local count = -1 cur = cur + 1 -- Get the maildirs/messages we'll operate upon. local objs if mode == "index" then objs = get_messages() elseif mode == "maildir" then objs = maildirs() end -- -- Start searching from the current-position -- while count < max do -- Bump to the next position, wrapping appropriately. cur = cur + 1 if cur > max then cur = 1 end if cur < 1 then cur = max end -- Does this position show an unread "thing" ? local unread = false if mode == "maildir" then -- A maildir is unread if it contains non-read messages. local folder = objs[cur] if folder and folder:unread_messages() > 0 then unread = true end elseif mode == "index" then -- A message is unread if .. it is unread! local msg = objs[cur] if msg and msg:is_new() then unread = true end end -- If we found an unread thing, then select it -- and return - first match wins. if unread then Config:set(mode .. ".current", (cur - 1)) return end count = count + 1 end warning_msg "There are no new items to select" end -- -- Toggle the panel height between "small" and "large". -- function panel_size_toggle () local h = Panel:height() if h == 6 then h = 16 else h = 6 end Panel:height(h) end -- -- This function jumps to the previous message, if possible. -- -- It is only called when viewing a single message. -- function prev_message () -- Get the messages, and sort. local msgs = get_messages() -- Get the current offset local cur = Config.get_with_default("index.current", 0) if cur > 0 then cur = cur - 1 Global:select_message(msgs[cur + 1]) Config:set("index.current", cur) end end -- -- This function jumps to the next message, if possible. -- -- It is only called when viewing a single message. -- function next_message () -- Get the messages, and sort. local msgs = get_messages() -- Get the current offset + max local cur = Config.get_with_default("index.current", 0) local max = Config:get "index.max" if cur < (max - 1) then cur = cur + 1 Global:select_message(msgs[cur + 1]) Config:set("index.current", cur) end end -- -- Define some utility functions for operating on all visible -- messages. -- -- NOTE: These are not bound to any key by default. -- ----------------------------------------------------------------------------- -- -- Mark all messages as having been read. -- function mark_all_read () local msgs = get_messages() if msgs and #msgs > 0 then Fun.object_map('mark_read', msgs) else warning_msg "There are no messages" end end -- -- Mark all messages as having NOT been read. -- function mark_all_new () local msgs = get_messages() if msgs and #msgs > 0 then Fun.object_map('mark_unread', msgs) else warning_msg "There are no messages" end end -- -- Delete all messages. -- function delete_all () local msgs = get_messages() if msgs and #msgs > 0 then Fun.object_map("delete", msgs) else warning_msg "There are no messages" end end -- -- Define some call-backs which are implemented at various times. -- ----------------------------------------------------------------------------- -- -- Completion system for Screen:get_line -- ----------------------------------------------------------------------------- -- -- For better completion results the completion function uses contexts. -- A context is a function named complete_context, which produces the completion -- options for this specific situation. Multiple contexts can be set at once. -- A context function is called with the whole line buffer and is -- responsible to find the right completion position and completions for its -- context. It has to return the token its completions are based on and a table -- of the actual completions. -- -- The following three contexts are available: -- * address: completing email addresses calling a external program specified in -- the config option "complete.addressbookcmd". -- * path: complete file paths. -- * lua: complete lua code including lumail's objects. -- -- To extend the completions add a function handling your custom context. -- And activate it using set_completion_context("your_context") before the use -- of Screen:get_line. -- -- For Example: To add environment variables to shell command completion define -- this function: -- --function complete_shell(buffer) -- local ret = {} -- local token = buffer:match(".*%${([^}]*)$") -- -- local handle = io.popen("env | cut -d '=' -f 1") -- local env_vars = handle:read("*a"):to_table() -- handle:close() -- -- for _, var in ipairs(env_vars) do -- if var:match("^" .. token) then -- table.insert(ret, var) -- end -- end -- return token, ret --end -- -- And activate it for read_execute like this: --do -- local _read_execute = read_execute -- read_execute = function() -- set_completion_context("shell") -- _read_execute() -- end --end -- -- An already existing context can be extended in a similar way. -- To add the email address of your mother to the completions do: -- --do -- local _complete_address = complete_address -- complete_address = function(buffer) -- -- get old completions -- local token, ret = _complete_address(buffer) -- -- add mothers mail -- table.insert(ret, "mother@domain.com") -- return token, ret -- end --end do -- -- Table to hold the completion contexts. -- local completion_context = {} function set_completion_context(...) completion_context = {} for i,v in ipairs({...}) do completion_context[v] = true end end -- --- Entry point for the TAB-Completion system for Screen:get_line. -- -- This function is called with the buffer Screen:get_line holds. It -- calls the functions for the specified contexts and collects their -- completions. After the user chose one completion the buffer is -- build with the completion and returned. -- function on_complete (line) -- The possible completions. local completions = {} -- The tokens we complete on. local tokens = {} -- Index mapping completions to their token. local index = {} -- -- Collect all possible completions and their tokens by calling -- the context complete functions. -- local i = 1 for context, _ in pairs(completion_context) do local func = _G["complete_"..context] if func then local token, options = func(line) table.insert(tokens, token) for _, option in ipairs(options) do table.insert(completions, option) -- Build our index. index[option] = i end else warning_msg("Unknown completion context: \"" .. context .. "\"") end end -- No completion possible if #completions == 0 then return "" end local choice = "" -- Only one completion? if #completions == 1 then choice = completions[1] else choice = Screen:choose_string(completions) end if choice ~= "" then -- Build the buffer to return local token = tokens[index[choice]] return line:sub(0, #line-#token) .. choice else return line end end -- -- Prompt for a path. -- Screen["get_path"] = function(self, prompt) set_completion_context("path") return Screen:get_line(prompt) end -- -- Prompt for an address. -- Screen["get_address"] = function(self, prompt) set_completion_context("address") return Screen:get_line(prompt) end -- -- Prompt for lua code. -- Screen["get_lua"] = function(self, prompt) set_completion_context("lua") return Screen:get_line(prompt) end -- -- Prompt for shell code. -- Screen["get_shell"] = function(self, prompt) set_completion_context("shell") return Screen:get_line(prompt) end end -- --- Complete addresses using an external addressbook. -- -- The completion is done after the last white space. -- -- The output of the external addressbook should contain one -- email address per line. -- function complete_address(buffer) local ret = {} -- Find the token to complete on. -- Our token starts at the last space char in buffer. local token_pos = (buffer:reverse():find("%s")) local token = "" if token_pos then token = buffer:sub(#buffer - token_pos + 2) else token = buffer end local cmd = Config:get("complete.addressbookcmd") -- Check if cmd is set if not cmd then return token, ret end local handle = io.popen(cmd .. " \"".. token .. "\"") local stdout = handle:read("*a") handle:close() local function insert_mailaddr(line) -- include @ to make sure we match the email address local mailaddr = line:match("([^%s]*@[^%s]*)%s") if mailaddr then table.insert(ret, mailaddr) end end -- get emails from output stdout:gsub("(.-)\r?\n", insert_mailaddr) return token, ret end -- --- Complete lua code. -- -- The completion is done after last occurence of ( ' " or space in the buffer. -- function complete_lua(buffer) local ret = {} -- Find the token to complete on. local token_pos = (buffer:reverse():find("[(\"'%s]")) local token = "" if token_pos then token = buffer:sub(#buffer - token_pos + 2) else token = buffer end -- -- Add in our local objects. -- local objs = {} objs["Config:"] = Config objs["Directory:"] = Directory objs["File:"] = File objs["Global:"] = Global objs["Log:"] = Log objs["Maildir:"] = Maildir objs["Message:"] = Message objs["Net:"] = Net objs["Panel:"] = Panel objs["Screen:"] = Screen for name, object in pairs(objs) do for key, _ in pairs(object) do if not string.match(key, "^_") and string.match(name .. key, "^" .. token) then table.insert(ret, name .. key) end end end -- -- Add in all user-defined functions. -- for k, _ in pairs(_G) do if string.match(k, "^" .. token) then table.insert(ret, k) end end -- -- Add path and email completions -- local _, paths = complete_path(token) for _, p in ipairs(paths) do table.insert(ret, p) end local _, addresses = complete_address(token) for _, a in ipairs(addresses) do table.insert(ret, a) end return token, ret end -- --- Complete shell. -- -- The completion is done after last occurence of ( ' " or space in the buffer. function complete_shell(buffer) local ret = {} -- Find the token to complete on. local token_pos = (buffer:reverse():find("[(\"'%s]")) local token = "" if token_pos then token = buffer:sub(#buffer - token_pos + 2) else token = buffer end -- -- Add path and email completions -- local _, paths = complete_path(token) for _, p in ipairs(paths) do table.insert(ret, p) end local _, addresses = complete_address(token) for _, a in ipairs(addresses) do table.insert(ret, a) end return token, ret end -- --- Complete paths. -- -- The completion is done on the whole buffer. -- function complete_path(buffer) local ret = {} local token = buffer -- -- If the token starts with "~" then replace that with -- the users' home-directory. -- if string.sub(token, 0, 1) == "~" then token = string.sub(token, 2) token = os.getenv "HOME" .. token -- -- If the token doesn't start with '/' it is a relative path -- and we have to make it absolute. -- elseif not string.match(token, "^/") then -- get pwd local handle = io.popen("pwd") local pwd = handle:read("*a"):sub(0,-2) -- strip new line handle:close() token = pwd .. "/" .. token end -- -- Get the last directory in the token. -- -- Default to / if we can't find one. -- dir = string.match(token, "^(.*)/") if dir == "" then dir = "/" end -- -- If the directory exists then add all the entries to the completion-set. -- if File:exists(dir) then entries = Directory:entries(dir) for i, v in ipairs(entries) do if string.match(v, "^" .. token) then if File:is_directory(v) then -- skip . and .. directories if not v:match(".*/%.%.?$") then table.insert(ret, v .. "/") end else table.insert(ret, v) end end end end return buffer, ret end -- -- Handle timers -- ----------------------------------------------------------------------------- -- -- In the past we'd expect users to override "on_idle()" to invoke -- the scheduling of "stuff", but this proved to be complex because -- it meant that all releases which touched this code would require -- rewriting. -- -- We already had some magic for keeping state, so this was improved -- and now you can define arbitrary functions that will be invoked -- on regular schedule just via their name. -- -- So, for example, define the function `on_XX()` in your personal -- configuration file. -- -- Assuming XX is a number then the function will be invoked at that -- frequency. So `on_1()` will be invoked every second, `on_2()` every -- two seconds, etc. -- -- As a concrete example this runs every five minutes: -- -- function on_300() -- Panel:append( "I'm called every five minutes" ) -- end -- -- The only caveat here is that you can only define one function -- for any given frequency. -- -- do -- -- Table to hold the idle-timers the user has defined. -- -- Key: The number of seconds between invocations. -- -- Val: A hash containing the function to invoke, and the last time -- it was invoked. -- local idle_timers = nil function on_idle () -- -- Initialize our callback-table, if it is empty. -- if idle_timers == nil then -- Ensure the timer-list is a table. idle_timers = {} -- Get the current time local now = os.time() -- Loop over all the things in the global scope. for n, o in pairs(_G) do -- Is it a function? if type(o) == "function" then -- Is the name of the function "on_NNN" ? local period = string.match(n, "^on%_(%d+)$") if period then -- Save the result away. idle_timers[period] = { callback = o, last = now, } end end end end -- -- Now we're in the routine that will run every second. -- ct = os.time() -- Iterate over our timer-table for i, o in pairs(idle_timers) do -- Get the function to invoke. local func = idle_timers[i]['callback'] -- Get the last time this function was last called. local last = idle_timers[i]['last'] -- If the threshold has passed then.. if (tonumber(ct) - tonumber(last)) >= tonumber(i) then -- Update the last-called time idle_timers[i]['last'] = ct -- Invoke the function. func() end end end -- -- End of closure. -- end -- -- This function is invoked before a message is sent. -- -- function on_message_send(path) -- -- -- Read the recipient of the message -- local m = Message.new(path) -- local to = m:header("To") -- to = string.match(to, "<(.*)>") or to -- -- -- Here we could update our outgoing mail-agent to use msmtp -- local mta = "/usr/bin/msmtp -a blah " .. to -- Config:set( "global.mailer", mta ) -- Panel:append( "Mailer set to " .. mta ) -- end -- -- This function, if it is defined, is invoked just after a message -- is sent. -- -- function on_message_sent(path) -- end -- -- Utility function to show what a key is bound to. -- -- Bound to "?" in keybinding-mode. -- function show_key_binding () local c = Screen:get_char "Input key?" if c == nil or c == "" then return end -- -- Lookup per-mode value. -- local mode = Config:get "global.mode" if keymap[mode] and keymap[mode][c] then Panel:append(c .. " is bound to " .. keymap[mode][c]) return end -- -- Lookup global mode. -- if keymap['global'][c] then Panel:append(c .. " is bound to " .. keymap['global'][c]) return end Panel:append(c .. " is not bound to anything") end -- -- Handle legacy setups -- if ( Directory:exists( "/etc/lumail2/perl.d" ) ) then info_msg( "WARNING: Obsolete directory detected /etc/lumail2/perl.d" ) info_msg( "Please see: https://lumail.org/install/#upgrade" ) end if ( Directory:exists( "/etc/lumail2/lib" ) ) then info_msg( "WARNING: Obsolete directory detected /etc/lumail2/perl.d" ) info_msg( "Please see: https://lumail.org/install/#upgrade" ) end -- -- Now load our per-user configuration files -- -- This is divided into two parts: -- -- * Legacy files which will be removed in the future. -- -- * New/Current files which are the preferred location for -- user configuration. -- local legacy_config = {} local current_config = {} -- Hostname-specific lua file. local hostname = Net:hostname() .. ".lua" table.insert( legacy_config, File:expand( "$HOME/.lumail2/lumail2.lua" ) ) table.insert( legacy_config, File:expand( "$HOME/.lumail2/config.lua" ) ) table.insert( legacy_config, File:expand( "$HOME/.lumail2/" .. hostname ) ) -- -- preferred/new/modern config-location -- table.insert( legacy_config, File:expand( "$XDG_CONFIG_HOME/lumail/lumail.lua" ) ) table.insert( legacy_config, File:expand( "$XDG_CONFIG_HOME/lumail/config.lua" ) ) table.insert( legacy_config, File:expand( "$XDG_CONFIG_HOME/lumail/" .. hostname ) ) table.insert( current_config, File:expand( "$HOME/.config/lumail/lumail.lua" ) ) table.insert( current_config, File:expand( "$HOME/.config/lumail/config.lua" ) ) table.insert( current_config, File:expand( "$HOME/.config/lumail/" .. hostname ) ) table.insert( current_config, File:expand( "$HOME/.lumail/lumail.lua" ) ) table.insert( current_config, File:expand( "$HOME/.lumail/config.lua" ) ) table.insert( current_config, File:expand( "$HOME/.lumail/" .. hostname ) ) -- -- Handle the loading of legacy config -- local warned = false for i, o in ipairs(legacy_config) do if ( File:exists( File:expand( o ) ) ) then dofile( File:expand( o ) ) if ( not warned ) then warning_msg( "Loading legacy config file - " .. o ) warning_msg( "See: https://lumail.org/getting-started/" ) warned = true end end end for i, o in ipairs(current_config) do if ( File:exists( File:expand( o ) ) ) then dofile( File:expand( o ) ) end end -- -- Handle any command-line arguments -- ----------------------------------------------------------------------------- for index, arg in ipairs(ARGS) do -- -- Look for --folder=foo -- local folder = string.match(arg, "--folder=(.*)") if folder then Config:set("maildir.limit", "all") Maildir.select(folder, true) end end