summaryrefslogtreecommitdiffstats
path: root/fetchmail-model.lua
blob: 8aa114f840300fd4f10392f82cc2d3b09adc355c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
module(..., package.seeall)

-- Load libraries
require("modelfunctions")
require("posix")
require("fs")
require("getopts")
require("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 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)
	if entryname and entryname ~= "" then
		config = config or parseconfigfile(fs.read_file(configfile))
		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
				return entry
			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
		entryline = entryline or {}
		insertentries = {}
		local reverseentry = {}
		for i,word in ipairs(entryline) do reverseentry[word] = i end
		-- Server options must come before user options, so add user option to end
		-- and add server options just after remotehost (3).  To do this, we'll use an array
		-- of insertentries that will be inserted at the end.  To delete entries, just set
		-- them to nil, creating a sparse array.  We can fix that at the end also
		-- Here are two helper functions
		function setserveroption(option, value)
			if reverseentry[option] then
				entryline[reverseentry[option]+1] = value
			else
				insertentries[table.maxn(insertentries)+1] = option
				insertentries[table.maxn(insertentries)+1] = value
			end
		end
		function setuseroption(option, value)
			if reverseentry[option] then
				entryline[reverseentry[option]+1] = value
			else
				entryline[table.maxn(entryline)+1] = option
				entryline[table.maxn(entryline)+1] = value
			end
		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
		if entrystruct.value.enabled.value then
			entryline[1] = "poll"
		else
			entryline[1] = "skip"
		end
		entryline[2] = entrystruct.value.remotehost.value
		-- generic stuff
		deleteoptionandvalue("proto")
		deleteoptionandvalue("local")
		deleteoptionandvalue("user")
		deleteoptionandvalue("pass")
		-- remove here and there
		for i=1,table.maxn(entryline) do
			if entryline[i] == "here" or entryline[i] == "there" then
				entryline[i] = nil
			end
		end
		if entrystruct.value.method.value == "pop3domain" then
			setserveroption("protocol", "pop3")
			setserveroption("localdomains", entrystruct.value.localdomain.value)
			deleteoptionandvalue("is")
			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("is")
			deleteoptionandvalue("to")
			deleteoptionandvalue("smtpaddress")
			setuseroption("fetchdomains", entrystruct.value.localdomain.value)
		else	-- Method not pop3domain or etrn
			setserveroption("protocol", entrystruct.value.method.value)
			deleteoptionandvalue("localdomains")
			setuseroption("is", entrystruct.value.localmailbox.value)
			deleteoptionandvalue("to")
			deleteoptionandvalue("smtpaddress")
			deleteoptionandvalue("fetchdomains")
		end
		if entrystruct.value.method.value == "etrn" then
			deletenooption("dns")
			deleteoptionandvalue("username")
			deleteoptionandvalue("password")
			deleteoptionandvalue("smtphost")
			deletenooption("rewrite")
			deleteoption("fetchall")
			deleteoption("ssl")
		else	-- Method not etrn
			if not reverseentry["dns"] then
				insertentries[table.maxn(insertentries)+1] = "no"
				insertentries[table.maxn(insertentries)+1] = "dns"
			end
			setuseroption("username", entrystruct.value.remotemailbox.value)
			setuseroption("password", '"'..entrystruct.value.remotepassword.value..'"')
			setuseroption("smtphost", entrystruct.value.localhost.value)
			if not reverseentry["rewrite"] then
				entryline[table.maxn(entryline)+1] = "no"
				entryline[table.maxn(entryline)+1] = "rewrite"
			end
			if not reverseentry["fetchall"] then
				entryline[table.maxn(entryline)+1] = "fetchall"
			end
			if entrystruct.value.ssl.value and not reverseentry["ssl"] then
				entryline[table.maxn(entryline)+1] = "ssl"
			elseif not entrystruct.value.ssl.value and reverseentry["ssl"] then
				entryline[reverseentry["ssl"]] = nil
			end
		end

		-- Now, insert the insertentries and remove the nil entries
		table.insert(insertentries, 1, entryline[1])
		table.insert(insertentries, 2, entryline[2])
		for i=3,table.maxn(entryline) do
			table.insert(insertentries, entryline[i])
		end
		insertentries.linenum = entryline.linenum
		entryline = insertentries
	end

	local file = fs.read_file(configfile)
	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, "rwx--x---")
	config = nil
end

local function validateentry(entry)
	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

	local success = true
	success = modelfunctions.validateselect(entry.value.method) 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 startstop_service(action)
	local cmd
	if action:lower() == "run" then
		cmd = "/usr/bin/fetchmail -d0 -v -f "..configfile.." 2>&1"
	elseif action:lower() == "test" then
		cmd = "/usr/bin/fetchmail -d0 -v -k -f "..configfile.." 2>&1"
	else
		return modelfunctions.startstop_service(processname, action)
	end
	local f = io.popen(cmd)
	cmdresult = f:read("*a")
	f:close()
	return cfe({ value=cmdresult, label="Start/Stop result" })
end

function getstatus()
	return modelfunctions.getstatus(processname, packagename, "Fetchmail Status")
end

function get_filedetails()
	-- FIXME - validation
	return modelfunctions.getfiledetails(configfile)
end

function update_filecontent(filedetails)
	filedetails.value.filename.value = configfile
	-- FIXME - validation
	local retval = modelfunctions.setfiledetails(filedetails)
	posix.chmod(configfile, "rwx--x---")
	config = nil
	return retval
end

function 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))
	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 = getopts.getoptsfromfile(confdfile, "", "polling_period")
	if confd then
		interval.value = confd
	end

	return cfe({ type="group", value={interval=interval, postmaster=postmaster, bounceerrors=bounceerrors}, label="Fetchmail Global Config" })
end

function updateconfig(conf)
	local success, conf = validateconfig(conf)

	if success then
		local file = fs.read_file(configfile)
		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
				line = "set postmaster "..conf.value.postmaster.value.."\n"
			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, "rwx--x---")
		config = nil

		getopts.setoptsinfile(confdfile, "", "polling_period", '"'..conf.value.interval.value..'"')
	else
		conf.errtxt = "Failed to set configuration"
	end

	return conf
end

function readentries()
	local entries = cfe({ type="structure", value={}, label="List of Fetchmail entries" })
	config = config or parseconfigfile(fs.read_file(configfile))
	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 = {}
			for i,word in ipairs(entry) do reverseentry[word] = i end
			local method = "error"
			if reverseentry["local"] or reverseentry["localdomains"] then
				method = "pop3domain"
			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
			table.insert(entries.value, {entry=entry[2], method=method, enabled=enabled})
		end
	end
	return entries
end

function readentry(entryname)
	local enabled = cfe({ type="boolean", value=true, label="Enable" })
	local method = cfe({ type="select", value="pop3", label="Method", option=methods })
	local remotehost = cfe({ value=entryname, label="Remote Host" })
	local remotemailbox = cfe({ label="Remote Mailbox" })
	local remotepassword = cfe({ label="Password" })
	local localhost = cfe({ label="Local Host" })
	local localmailbox = cfe({ label="Local Mailbox" })
	local localdomain = cfe({ label="Local Domain" })
	local ssl = cfe({ type="boolean", value=false, label="SSL Encryption" })

	local entry = findentryline(entryname)
	if entry then
		if entry[1] == "skip" then
			enabled.value = false
		end
		local reverseentry = {}
		for i,word in ipairs(entry) do reverseentry[word] = i end
		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
	end

	return cfe({ type="group", value={enabled=enabled, method=method, remotehost=remotehost, remotemailbox=remotemailbox, remotepassword=remotepassword, localhost=localhost, localmailbox=localmailbox, localdomain=localdomain, ssl=ssl}, label="Fetchmail Entry" })
end

function updateentry(entrystruct)
	local success, entrystruct = validateentry(entrystruct)
	local entry = findentryline(entrystruct.value.remotehost.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 createentry(entrystruct)
	local success, entrystruct = validateentry(entrystruct)
	local entry = findentryline(entrystruct.value.remotehost.value)
	if entry then
		entrystruct.value.remotehost.errtxt = "Entry already exists"
		success = false
	end

	if success then
		writeentryline(entrystruct)
	else
		entrystruct.errtxt = "Failed to update entry"
	end

	return entrystruct
end

function deleteentry(entryname)
	local retval = cfe({ value="Deleted entry", label="Delete Fetchmail Entry Result" })
	local entry = findentryline(entryname)
	if entry then
		writeentryline(nil, entry)
	else
		retval.value = "Failed to delete entry - not found"
	end

	return retval
end