local mymodule = {} -- Load libraries modelfunctions = require("modelfunctions") posix = require("posix") fs = require("acf.fs") format = require("acf.format") validator = require("acf.validator") -- Set variables local packagename = "fetchmail" local processname = "fetchmail" local configfile = "/etc/fetchmailrc" local confdfile = "/etc/conf.d/fetchmail" local config local methods = {"pop3","imap","pop3domain", "etrn", } -- ################################################################################ -- LOCAL FUNCTIONS local function findkeywordsinentry(entry) local reverseentry = {} -- we can't just do a simple reverse table in case keywords are used as parameters (ie. password = password) --for i,word in ipairs(entry) do reverseentry[word] = i end -- so, have to parse word by word -- the following is a list of the keywords we know about in this ACF -- dns, fetchall, fetchdomains, is, local, localdomains, pass, password, proto, protocol, rewrite, smtphost, ssl, user, username, envelope -- array of keywords that take at least one parameter (don't know how to handle multiple parameters) local keywords = { "fetchdomains", "is", "local", "localdomains", "pass", "password", "proto", "protocol", "smtphost", "user", "username" } local reversekeywords = {} for i,word in ipairs(keywords) do reversekeywords[word] = i end local i=0 while i<#entry do i = i+1 reverseentry[entry[i]] = i if reversekeywords[entry[i]] then i = i+1 end end return reverseentry end local function parseconfigfile(file) file = file or "" local retval = {} local linenum=0 for line in string.gmatch(file, "([^\n]*)\n?") do linenum=linenum+1 if not string.match(line, "^%s*$") and not string.match(line, "^%s*#") then table.insert(retval, {linenum=linenum}) -- Iterate through each word, being careful about quoted strings and comments local offset = 1 while string.find(line, "%S+", offset) do local word = string.match(line, "%S+", offset) local endword = select(2, string.find(line, "%S+", offset)) if string.find(word, "^#") then break elseif string.find(word, "^\"") then endword = select(2, string.find(line, "\"[^\"]*\"", offset)) word = string.sub(line, string.find(line, "\"", offset)+1, endword-1) end table.insert(retval[#retval], word) offset = endword + 1 end end end return retval end local function findentryline(entryname, method, remotemailbox, localdomain) if entryname and entryname ~= "" then config = config or parseconfigfile(fs.read_file(configfile) or "") for i,entry in ipairs(config or {}) do if (entry[1] == "server" or entry[1] == "poll" or entry[1] == "skip") and entry[2] == entryname then local reverseentry = findkeywordsinentry(entry) -- For pop3domain, check the localdomain if method == "pop3domain" and (reverseentry["local"] or reverseentry["localdomains"]) then if entry[(reverseentry["local"] or reverseentry["localdomains"])+1] == localdomain then return entry end elseif reverseentry["proto"] or reverseentry["protocol"] then local protocol = entry[(reverseentry["proto"] or reverseentry["protocol"])+1] -- For etrn, no further check if method == "etrn" and protocol == "etrn" then return entry -- For pop3 and imap, check the username elseif protocol == method and (method == "pop3" or method == "imap") then if reverseentry["username"] and entry[reverseentry["username"]+1] == remotemailbox then return entry end end end end end end return nil end local function writeentryline(entrystruct, entryline) if not entrystruct and not entryline then return end -- If there is a structure, create the entryline array if entrystruct then -- We'll use a reverseentry array to tell where entries are in entryline local reverseentry = {} if entryline then reverseentry = findkeywordsinentry(entryline) -- to handle entries that have multiple names function equateentries(option1,option2) if reverseentry[option1] then reverseentry[option2] = reverseentry[option1] else reverseentry[option1] = reverseentry[option2] end end equateentries("proto", "protocol") equateentries("local", "localdomains") equateentries("auth", "authenticate") equateentries("user", "username") equateentries("pass", "password") else entryline = {} end -- From http://fetchmail.berlios.de/fetchmail-man.html: -- "All user options must begin with a user description (user or username option) -- and follow all server descriptions and options." -- So, what we'll do is build two new server/user option arrays. -- Then, we'll add in the options we don't understand and combine the arrays. -- So, our options will be in order and unknown ones will be at the end of each section. local serveroptions = {} local useroptions = {} -- Here are some helper functions to set option values and delete from entryline function setserveroption(option, value) serveroptions[#serveroptions+1] = option serveroptions[#serveroptions+1] = value deleteoptionandvalue(option) end function setuseroption(option, value) useroptions[#useroptions+1] = option useroptions[#useroptions+1] = value deleteoptionandvalue(option) end function deleteoption(option) if reverseentry[option] then entryline[reverseentry[option]] = nil end end function deleteoptionandvalue(option) if reverseentry[option] then entryline[reverseentry[option]] = nil entryline[reverseentry[option]+1] = nil end end function deletenooption(option) if reverseentry[option] then entryline[reverseentry[option]] = nil local test = entryline[reverseentry[option]-1] if test and test == "no" then entryline[reverseentry[option]-1] = nil end end end -- Now we can start to set stuff -- First, the first two entries if entrystruct.value.enabled.value then serveroptions[1] = "poll" else serveroptions[1] = "skip" end serveroptions[2] = entrystruct.value.remotehost.value entryline[1] = nil entryline[2] = nil -- remove here and there if reverseentry.here then entryline[reverseentry.here] = nil end if reverseentry.there then entryline[reverseentry.there] = nil end -- Now we get to the interesting stuff if entrystruct.value.method.value == "etrn" then deleteoptionandvalue("username") deleteoptionandvalue("password") deleteoptionandvalue("is") deleteoptionandvalue("smtphost") deleteoption("ssl") deletenooption("rewrite") deleteoption("fetchall") deletenooption("dns") else -- Method not etrn setuseroption("username", entrystruct.value.remotemailbox.value) setuseroption("password", '"'..entrystruct.value.remotepassword.value..'"') if entrystruct.value.method.value == "pop3domain" then deleteoptionandvalue("is") else setuseroption("is", entrystruct.value.localmailbox.value) end setuseroption("smtphost", entrystruct.value.localhost.value) if entrystruct.value.ssl.value and not reverseentry["ssl"] then useroptions[#useroptions+1] = "ssl" elseif not entrystruct.value.ssl.value and reverseentry["ssl"] then entryline[reverseentry["ssl"]] = nil end if not reverseentry["rewrite"] then useroptions[#useroptions+1] = "no" useroptions[#useroptions+1] = "rewrite" end if not reverseentry["fetchall"] then useroptions[#useroptions+1] = "fetchall" end if not reverseentry["dns"] then serveroptions[#serveroptions+1] = "no" serveroptions[#serveroptions+1] = "dns" end end if entrystruct.value.method.value == "pop3domain" then setserveroption("protocol", "pop3") setserveroption("localdomains", entrystruct.value.localdomain.value) setuseroption("to", "*") setuseroption("smtpaddress", entrystruct.value.localdomain.value) deleteoptionandvalue("fetchdomains") elseif entrystruct.value.method.value == "etrn" then setserveroption("protocol", entrystruct.value.method.value) deleteoptionandvalue("localdomains") deleteoptionandvalue("to") deleteoptionandvalue("smtpaddress") setuseroption("fetchdomains", entrystruct.value.localdomain.value) else -- Method not pop3domain or etrn setserveroption("protocol", entrystruct.value.method.value) deleteoptionandvalue("localdomains") deleteoptionandvalue("to") deleteoptionandvalue("smtpaddress") deleteoptionandvalue("fetchdomains") end -- envelope is tough because it may have a no before or one or two values after -- first, delete any envelope option (preserving the count) local envelopecount if reverseentry["envelope"] then if entryline[reverseentry["envelope"]-1] == "no" then entryline[reverseentry["envelope"]-1] = nil else if validator.is_integer(entryline[reverseentry["envelope"]+1] or "") then -- Keep the number if not changing envelope option if entryline[reverseentry["envelope"]+2] == entrystruct.value.envelope.value then envelopecount = entryline[reverseentry["envelope"]+1] end entryline[reverseentry["envelope"]+2] = nil end entryline[reverseentry["envelope"]+1] = nil end entryline[reverseentry["envelope"]] = nil end if entrystruct.value.method.value == "pop3domain" then if entrystruct.value.envelope.value == "disabled" then serveroptions[#serveroptions+1] = "no" serveroptions[#serveroptions+1] = "envelope" else serveroptions[#serveroptions+1] = "envelope" serveroptions[#serveroptions+1] = envelopecount serveroptions[#serveroptions+1] = entrystruct.value.envelope.value end end -- Now, insert the remaining options for i=1,reverseentry["username"] or table.maxn(entryline) do serveroptions[#serveroptions+1] = entryline[i] end for i=reverseentry["username"] or table.maxn(entryline), table.maxn(entryline) do useroptions[#useroptions+1] = entryline[i] end local linenum = entryline.linenum entryline = serveroptions for i,val in ipairs(useroptions) do entryline[#entryline+1] = val end entryline.linenum = linenum end local file = fs.read_file(configfile) or "" local lines = {file} if entryline and entryline.linenum then -- Split the file to remove the line local startchar, endchar = string.match(file, string.rep("[^\n]*\n", entryline.linenum-1) .. "()[^\n]*\n()") if startchar and endchar then lines[1] = string.sub(file, 1, startchar-1) lines[2] = string.sub(file, endchar, -1) end end if entryline and entrystruct then table.insert(lines, 2, table.concat(entryline," ").."\n") end fs.write_file(configfile, string.gsub(table.concat(lines), "\n+$", "")) posix.chmod(configfile, "rw-------") posix.chown(configfile, posix.getpasswd("fetchmail", "uid") or 0) config = nil end local function validateentry(entry) local success = true function cannotbeblank(value) if value.value == "" then value.errtxt = "Invalid entry - cannot be blank" success = false end end function mustbeblank(value) if value.value ~= "" then value.errtxt = "Invalid entry - must be blank for this method" success = false end end success = modelfunctions.validateselect(entry.value.method) and success success = modelfunctions.validateselect(entry.value.envelope) and success if string.find(entry.value.remotehost.value, "[^%w%.%-]") then entry.value.remotehost.errtxt = "Invalid entry - may only contain alphanumeric, '.', or '-'" success = false end if string.find(entry.value.remotemailbox.value, "[^%w%.%-_@]") then entry.value.remotemailbox.errtxt = "Invalid entry" success = false end if string.find(entry.value.remotepassword.value, "%s") then entry.value.remotepassword.errtxt = "Invalid entry - cannot contain whitespace" success = false end if string.find(entry.value.localhost.value, "[^%w%.%-]") then entry.value.localhost.errtxt = "Invalid entry - may only contain alphanumeric, '.', or '-'" success = false end if string.find(entry.value.localmailbox.value, "[^%w%.%-_@]") then entry.value.localmailbox.errtxt = "Invalid entry" success = false end if string.find(entry.value.localdomain.value, "[^%w%.%-]") then entry.value.localdomain.errtxt = "Invalid entry - may only contain alphanumeric, '.', or '-'" success = false end cannotbeblank(entry.value.remotehost) if entry.value.method.value == "etrn" then mustbeblank(entry.value.remotemailbox) mustbeblank(entry.value.remotepassword) mustbeblank(entry.value.localhost) mustbeblank(entry.value.localmailbox) cannotbeblank(entry.value.localdomain) else cannotbeblank(entry.value.remotemailbox) cannotbeblank(entry.value.remotepassword) cannotbeblank(entry.value.localhost) if entry.value.method.value == "pop3domain" then mustbeblank(entry.value.localmailbox) cannotbeblank(entry.value.localdomain) else cannotbeblank(entry.value.localmailbox) mustbeblank(entry.value.localdomain) end end return success, entry end local function validateconfig(conf) local success = true if not validator.is_integer(conf.value.interval.value) then conf.value.interval.errtxt = "Invalid entry - must be an integer number" success = false end if string.find(conf.value.postmaster.value, "[^%w%.%-_@]") then conf.value.postmaster.errtxt = "Invalid entry" success = false end return success, conf end -- ################################################################################ -- PUBLIC FUNCTIONS function mymodule.get_startstop(self, clientdata) local actions = {"Run", "Test"} return cfe({ type="group", label="Management", value={}, option=actions }) end function mymodule.startstop_service(self, startstop, action) if action and (action:lower() == "run" or action:lower() == "test") then local cmd if action:lower() == "run" then cmd = "/usr/bin/fetchmail -d0 -v --nosyslog -f "..configfile elseif action:lower() == "test" then cmd = "/usr/bin/fetchmail -d0 -v -k --nosyslog -f "..configfile end startstop.descr, startstop.errtxt = modelfunctions.run_executable({"su", "-s", "/bin/sh", "-c", cmd, "-", "fetchmail"}, true) else startstop.errtxt = "Invalid action" end return startstop end function mymodule.getstatus() return modelfunctions.getstatus(processname, packagename, "Fetchmail Status") end function mymodule.get_filedetails() -- FIXME - validation return modelfunctions.getfiledetails(configfile) end function mymodule.update_filecontent(self, filedetails) -- FIXME - validation local retval = modelfunctions.setfiledetails(self, filedetails, {configfile}) posix.chmod(configfile, "rw-------") posix.chown(configfile, posix.getpasswd("fetchmail", "uid") or 0) config = nil return retval end function mymodule.getconfig() local interval = cfe({ value="60", label="Polling Interval", descr="Interval in seconds" }) local postmaster = cfe({ label="Postmaster", descr="If defined, undeliverable mail is sent to this account, otherwise it is discarded" }) local bounceerrors = cfe({ type="boolean", value=true, label="Bounce Errors", descr="Bounce errors back to the sender or send them to the postmaster" }) config = config or parseconfigfile(fs.read_file(configfile) or "") for i,entry in ipairs(config or {}) do if entry[2] == "postmaster" and entry[1] == "set" then postmaster.value = entry[3] or "" elseif entry[3] == "bouncemail" and entry[2] == "no" and entry[1] == "set" then bounceerrors.value = false end end local confd = format.parse_ini_file(fs.read_file(confdfile) or "", "", "polling_period") if confd then interval.value = string.sub(confd, 2, -2) end return cfe({ type="group", value={interval=interval, postmaster=postmaster, bounceerrors=bounceerrors}, label="Fetchmail Global Config" }) end function mymodule.updateconfig(self, conf) local success, conf = validateconfig(conf) if success then local file = fs.read_file(configfile) or "" local foundpostmaster, foundbounceerrors local lines = {} for line in string.gmatch(file, "([^\n]*\n?)") do if not foundpostmaster and string.match(line, "^%s*set%s+postmaster%s") then foundpostmaster = true if conf.value.postmaster.value ~= "" then line = "set postmaster "..conf.value.postmaster.value.."\n" else line = nil end elseif not foundbounceerrors and string.match(line, "^%s*set%s+no%s+bouncemail%s") then foundbounceerrors = true if conf.value.bounceerrors.value then line = nil end end lines[#lines + 1] = line end if not foundpostmaster then table.insert(lines, 1, "set postmaster "..conf.value.postmaster.value.."\n") end if not foundbounceerrors and not conf.value.bounceerrors.value then table.insert(lines, 1, "set no bouncemail\n") end fs.write_file(configfile, table.concat(lines)) posix.chmod(configfile, "rw-------") posix.chown(configfile, posix.getpasswd("fetchmail", "uid") or 0) config = nil fs.write_file(confdfile, format.update_ini_file(fs.read_file(confdfile) or "", "", "polling_period", '"'..conf.value.interval.value..'"')) else conf.errtxt = "Failed to set configuration" end return conf end function mymodule.readentries() local entries = cfe({ type="structure", value={}, label="List of Fetchmail entries" }) config = config or parseconfigfile(fs.read_file(configfile) or "") for i,entry in ipairs(config or {}) do if (entry[1] == "server" or entry[1] == "poll" or entry[1] == "skip") and entry[2] then local reverseentry = findkeywordsinentry(entry) local method = "error" local localdomain = "" if reverseentry["local"] or reverseentry["localdomains"] then method = "pop3domain" if entry[(reverseentry["local"] or reverseentry["localdomains"])+1] then localdomain = entry[(reverseentry["local"] or reverseentry["localdomains"])+1] end elseif reverseentry["proto"] or reverseentry["protocol"] then method = entry[(reverseentry["proto"] or reverseentry["protocol"])+1] or method end local enabled = true if entry[1] == "skip" then enabled=false end local username = "" if reverseentry["username"] and entry[reverseentry["username"]+1] then username = entry[reverseentry["username"]+1] end table.insert(entries.value, {remotehost=entry[2], method=method, enabled=enabled, remotemailbox=username, localdomain=localdomain}) end end return entries end function mymodule.readentry(entryname, meth, remotemailbx, localdom) local enabled = cfe({ type="boolean", value=true, label="Enable", seq=2 }) local method = cfe({ type="select", value="pop3", label="Method", option=methods, seq=3 }) local remotehost = cfe({ value=entryname, label="Remote Host", seq=1 }) local remotemailbox = cfe({ label="Remote Mailbox", seq=4 }) local remotepassword = cfe({ label="Password", seq=5 }) local localhost = cfe({ label="Local Host", seq=7 }) local localmailbox = cfe({ label="Local Mailbox", seq=8 }) local localdomain = cfe({ label="Local Domain", seq=9 }) local ssl = cfe({ type="boolean", value=false, label="SSL Encryption", seq=6 }) local envelope = cfe({ type="select", value="X-Envelope-To", label="Envelope Mode", option={"X-Original-To", "Delivered-To", "X-Envelope-To", "Received", "disabled"}, seq=10 }) local entry = findentryline(entryname, meth, remotemailbx, localdom) if entry then remotehost.readonly = true method.type = "text" method.readonly = true remotemailbox.readonly = true localdomain.readonly = true if entry[1] == "skip" then enabled.value = false end local reverseentry = findkeywordsinentry(entry) if reverseentry["local"] or reverseentry["localdomains"] then localdomain.value = entry[(reverseentry["local"] or reverseentry["localdomains"])+1] or localdomain.value method.value = "pop3domain" elseif reverseentry["proto"] or reverseentry["protocol"] then method.value = entry[(reverseentry["proto"] or reverseentry["protocol"])+1] or method.value end if reverseentry["user"] or reverseentry["username"] then remotemailbox.value = entry[(reverseentry["user"] or reverseentry["username"])+1] or remotemailbox.value end if reverseentry["pass"] or reverseentry["password"] then remotepassword.value = entry[(reverseentry["pass"] or reverseentry["password"])+1] or remotepassword.value end if reverseentry["smtphost"] then localhost.value = entry[reverseentry["smtphost"]+1] or localhost.value end if reverseentry["is"] then localmailbox.value = entry[reverseentry["is"]+1] or localmailbox.value end if reverseentry["fetchdomains"] then localdomain.value = entry[reverseentry["fetchdomains"]+1] or localdomain.value end if reverseentry["ssl"] then ssl.value = true end if reverseentry["envelope"] then if entry[reverseentry["envelope"]-1] == "no" then envelope.value = "disabled" elseif validator.is_integer(entry[reverseentry["envelope"]+1] or "") then envelope.value = entry[reverseentry["envelope"]+2] else envelope.value = entry[reverseentry["envelope"]+1] end end end return cfe({ type="group", value={enabled=enabled, method=method, remotehost=remotehost, remotemailbox=remotemailbox, remotepassword=remotepassword, localhost=localhost, localmailbox=localmailbox, localdomain=localdomain, ssl=ssl, envelope=envelope}, label="Fetchmail Entry" }) end function mymodule.updateentry(self, entrystruct) local success, entrystruct = validateentry(entrystruct) local entry = findentryline(entrystruct.value.remotehost.value, entrystruct.value.method.value, entrystruct.value.remotemailbox.value, entrystruct.value.localdomain.value) if not entry then entrystruct.value.remotehost.errtxt = "Entry not found" success = false end if success then writeentryline(entrystruct, entry) else entrystruct.errtxt = "Failed to update entry" end return entrystruct end function mymodule.createentry(self, entrystruct) local success, entrystruct = validateentry(entrystruct) local entry = findentryline(entrystruct.value.remotehost.value, entrystruct.value.method.value, entrystruct.value.remotemailbox.value, entrystruct.value.localdomain.value) if entry then entrystruct.value.remotehost.errtxt = "Entry already exists" success = false end if success then writeentryline(entrystruct) else entrystruct.errtxt = "Failed to create entry" end return entrystruct end function mymodule.get_deleteentry(self, clientdata) local retval = {} retval.remotehost = cfe({ value=clientdata.remotehost or "", label="Remote Host" }) retval.method = cfe({ type="select", value=clientdata.method or "pop3", label="Method", option=methods }) retval.remotemailbox = cfe({ value=clientdata.remotemailbox or "", label="Remote Mailbox" }) retval.localdomain = cfe({ value=clientdata.localdomain or "", label="Local Domain" }) return cfe({ type="group", value=retval, label="Delete Entry" }) end function mymodule.deleteentry(self, ent) local entry = findentryline(ent.value.remotehost.value, ent.value.method.value, ent.value.remotemailbox.value, ent.value.localdomain.value) if entry then writeentryline(nil, entry) else ent.errtxt = "Failed to delete entry - not found" end return ent end return mymodule