Commit b3f1c3e0ab6c

Vincent Demeester <vincent@sbr.pm>
2026-01-14 10:21:59
feat(imapfilter): add multi-criteria rules and move deletes to Trash
- Add support for multi-criteria rules with AND logic (e.g., from:sender subject:pattern) - Single-criterion rules maintain OR logic for backward compatibility - Add body: rule type support - Change delete action to move messages to Trash instead of permanent deletion - Refactor apply_rules to use helper function for criterion matching Multi-criteria rules allow more precise filtering, preventing marketing emails from matching receipt filters while still catching actual order confirmations. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 5daa78b
Changed files (1)
home
common
home/common/services/imapfilter-config.lua
@@ -35,39 +35,53 @@ account = IMAP {
 
 -- Load rules from external file
 -- Returns a table with rules organized by type
+-- Supports both single-criterion (OR logic) and multi-criterion (AND logic) rules
 function load_rules_from_file(filepath)
 	local rules = {
-		from = {},      -- Sender email addresses
-		domain = {},    -- Email domains
-		subject = {},   -- Subject patterns
-		header = {}     -- Header field patterns
+		from = {},      -- Sender email addresses (OR logic)
+		domain = {},    -- Email domains (OR logic)
+		subject = {},   -- Subject patterns (OR logic)
+		header = {},    -- Header field patterns (OR logic)
+		body = {},      -- Body patterns (OR logic)
+		multi = {}      -- Multi-criteria rules (AND logic within each rule)
 	}
-	
+
 	local file = io.open(filepath, "r")
 	if not file then
 		print("Warning: Could not open rules file: " .. filepath)
 		return rules
 	end
-	
+
 	for line in file:lines() do
 		-- Skip comments and blank lines
 		line = line:match("^%s*(.-)%s*$")  -- Trim whitespace
 		if line ~= "" and not line:match("^#") then
-			-- Parse rule format: TYPE:PATTERN
-			local rule_type, pattern = line:match("^(%w+):(.+)$")
-			if rule_type and pattern then
+			-- Parse multi-criteria format: TYPE:PATTERN TYPE:PATTERN ...
+			-- Count how many rule types are on this line
+			local criteria = {}
+			for rule_type, pattern in line:gmatch("(%w+):([^%s]+)") do
 				rule_type = rule_type:lower()
+				table.insert(criteria, {type = rule_type, pattern = pattern})
+			end
+
+			if #criteria == 0 then
+				print("Warning: Invalid rule format: " .. line)
+			elseif #criteria == 1 then
+				-- Single criterion - add to OR set (backward compatible)
+				local rule_type = criteria[1].type
+				local pattern = line:match("^%w+:(.+)$")  -- Get full pattern (may contain spaces)
 				if rules[rule_type] then
 					table.insert(rules[rule_type], pattern)
 				else
 					print("Warning: Unknown rule type '" .. rule_type .. "' in " .. filepath)
 				end
 			else
-				print("Warning: Invalid rule format (expected TYPE:PATTERN): " .. line)
+				-- Multi-criteria - add to AND set
+				table.insert(rules.multi, criteria)
 			end
 		end
 	end
-	
+
 	file:close()
 	return rules
 end
@@ -76,40 +90,76 @@ end
 -- Rule Application Functions
 ----------
 
+-- Apply a single criterion to a message set
+function apply_single_criterion(messages, criterion_type, pattern)
+	if criterion_type == 'from' then
+		return messages:contain_from(pattern)
+	elseif criterion_type == 'domain' then
+		return messages:contain_from(pattern)
+	elseif criterion_type == 'subject' then
+		return messages:contain_subject(pattern)
+	elseif criterion_type == 'body' then
+		return messages:contain_body(pattern)
+	elseif criterion_type == 'header' then
+		-- Parse HEADER-NAME:PATTERN
+		local header_name, header_pattern = pattern:match("^([^:]+):(.+)$")
+		if header_name and header_pattern then
+			return messages:contain_header(header_name, header_pattern)
+		else
+			print("Warning: Invalid header pattern: " .. pattern)
+			return Set {}
+		end
+	else
+		print("Warning: Unknown criterion type: " .. criterion_type)
+		return Set {}
+	end
+end
+
 -- Apply rules to a message set
 function apply_rules(messages, rules, action, folder_name)
 	local results = Set {}
-	
-	-- Apply from: rules (sender email)
+
+	-- Apply single-criterion rules (OR logic)
 	for _, sender in ipairs(rules.from or {}) do
 		results = results + messages:contain_from(sender)
 	end
-	
-	-- Apply domain: rules (entire domain)
+
 	for _, domain in ipairs(rules.domain or {}) do
-		-- Match emails ending with the domain
 		results = results + messages:contain_from(domain)
 	end
-	
-	-- Apply subject: rules (subject patterns)
+
 	for _, pattern in ipairs(rules.subject or {}) do
 		results = results + messages:contain_subject(pattern)
 	end
-	
-	-- Apply header: rules (custom headers)
+
+	for _, pattern in ipairs(rules.body or {}) do
+		results = results + messages:contain_body(pattern)
+	end
+
 	for _, header_pattern in ipairs(rules.header or {}) do
-		-- Parse HEADER-NAME:PATTERN
 		local header_name, pattern = header_pattern:match("^([^:]+):(.+)$")
 		if header_name and pattern then
 			results = results + messages:contain_header(header_name, pattern)
 		end
 	end
-	
+
+	-- Apply multi-criteria rules (AND logic within each rule, OR between rules)
+	for _, criteria_list in ipairs(rules.multi or {}) do
+		-- Start with all messages, then intersect with each criterion
+		local multi_results = messages
+		for _, criterion in ipairs(criteria_list) do
+			multi_results = multi_results * apply_single_criterion(messages, criterion.type, criterion.pattern)
+		end
+		results = results + multi_results
+	end
+
 	-- Perform the action
 	if #results > 0 then
 		print(string.format("  → Matched %d message(s)", #results))
 		if action == 'delete' then
-			results:delete_messages()
+			-- Move to Trash instead of permanent deletion
+			account:create_mailbox('Trash')
+			results:move_messages(account['Trash'])
 		elseif action == 'archive' then
 			archive_by_year(results)
 		elseif action == 'move' then