Commit 307e92322194

Vincent Demeester <vincent@sbr.pm>
2026-01-13 13:34:38
refactor(imapfilter): load rules from external private repo
Move email filtering rules from hardcoded Lua tables to external rule files in a private repository. This keeps 25 email addresses and filtering patterns out of the public home repository. Changes: - Replace hardcoded sender lists with rule file parser - Load rules from ~/.local/share/imapfilter-rules/ - Support multiple rule types: from, domain, subject, header - Keep helper functions (archive_by_year, etc.) - Remove sensitive email addresses from public repo Rules are now maintained in private imapfilter-rules repository: - delete.txt: 14 unwanted marketing senders - receipts.txt: 9 e-commerce confirmations - newsletters.txt: 12 newsletter subscriptions - archive.txt: Template for archival rules Requires private repo to be cloned to ~/.local/share/imapfilter-rules/ on hosts running imapfilter. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent ee78c14
Changed files (1)
home
common
home/common/services/imapfilter-config.lua
@@ -10,93 +10,87 @@ options.subscribe = true
 account = IMAP {
 	server = 'imap.mail.me.com',
 	username = 'vdemeester@icloud.com',
-	-- Password will be provided via command line or config file
-	-- Using ssl by default
+	-- Password will be provided via command line (-p flag)
 	ssl = 'tls1.2',
 }
 
 ----------
--- Example filtering rules
--- Customize these based on your needs
+-- Rule File Parser
 ----------
 
--- Get all messages in INBOX
-messages = account['INBOX']:select_all()
-
--- Example 1: Move GitHub notifications to a GitHub folder
--- Uncomment and customize as needed
-github = messages:contain_from('notifications@github.com')
-github:move_messages(account['GitHub'])
-
--- Example 2: Move mailing list emails
--- Uncomment and customize as needed
--- lists = messages:contain_to('list@example.com')
--- lists:move_messages(account['Lists'])
-
--- Example 3: Filter by subject
--- Uncomment and customize as needed
--- spam = messages:contain_subject('[SPAM]')
--- spam:move_messages(account['Junk'])
-
-----------
--- Helper Functions
-----------
-
--- Extract year from email date header
--- @param message: single message to extract year from
--- @return year as string (e.g., "2025")
-function get_message_year(message)
-	-- Get the message headers including Date
-	local mbox, uid = table.unpack(message)
-	local date_header = mbox[uid]:fetch_header()
-
-	-- Try to extract year from Date header
-	-- Date format is usually like: "Thu, 10 Dec 2025 10:30:45 +0100"
-	local year = date_header:match("Date:.-(%d%d%d%d)")
-
-	-- Fallback to current year if we can't parse the date
-	if not year then
-		year = os.date("%Y")
+-- Load rules from external file
+-- Returns a table with rules organized by type
+function load_rules_from_file(filepath)
+	local rules = {
+		from = {},      -- Sender email addresses
+		domain = {},    -- Email domains
+		subject = {},   -- Subject patterns
+		header = {}     -- Header field patterns
+	}
+	
+	local file = io.open(filepath, "r")
+	if not file then
+		print("Warning: Could not open rules file: " .. filepath)
+		return rules
 	end
-
-	return year
-end
-
--- Archive messages by year
--- @param message_set: set of messages to archive
-function archive_by_year(message_set)
-	-- Group messages by year
-	local messages_by_year = {}
-
-	for _, msg in ipairs(message_set) do
-		local year = get_message_year(msg)
-		if not messages_by_year[year] then
-			messages_by_year[year] = Set {}
+	
+	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
+				rule_type = rule_type:lower()
+				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)
+			end
 		end
-		messages_by_year[year] = messages_by_year[year] + Set {msg}
-	end
-
-	-- Move messages to year-based archive folders
-	for year, msgs in pairs(messages_by_year) do
-		local folder_name = 'Archive/' .. year
-		-- Ensure the folder exists
-		account:create_mailbox(folder_name)
-		msgs:move_messages(account[folder_name])
 	end
+	
+	file:close()
+	return rules
 end
 
--- Filter messages from a list of senders
--- @param messages: message set to filter
--- @param senders: table/list of email addresses
--- @param action: 'archive', 'move', or 'delete'
--- @param folder_name: folder name for moving (only used for 'move' action)
-function filter_by_senders(messages, senders, action, folder_name)
-	local results = Set {}
+----------
+-- Rule Application Functions
+----------
 
-	for _, sender in ipairs(senders) do
+-- Apply rules to a message set
+function apply_rules(messages, rules, action, folder_name)
+	local results = Set {}
+	
+	-- Apply from: rules (sender email)
+	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 _, 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
+	
+	-- Perform the action
 	if action == 'delete' then
 		results:delete_messages()
 	elseif action == 'archive' then
@@ -106,56 +100,84 @@ function filter_by_senders(messages, senders, action, folder_name)
 			print("Error: folder_name required for 'move' action")
 			return results
 		end
+		-- Ensure folder exists
+		account:create_mailbox(folder_name)
 		results:move_messages(account[folder_name])
 	else
-		print("Unknown action: " .. action .. ". Use 'archive', 'move', or 'delete'")
+		print("Unknown action: " .. action)
 	end
-
+	
 	return results
 end
 
 ----------
--- Email Category Filters
+-- Helper Functions
 ----------
 
-local todelete = {
-	'contact@news.probikeshop.com',
-	'adidas@fr-news.adidas.com',
-	'contact@email.westfield.com',
-	'soouest@email.westfield.com',
-	'geox@email-geox.com',
-	'shop@emails.flic.io',
-	'shop@mail.nova.fr',
-	'noreply-marketplace.partner@decathlon.com',
-	'noreply@e-ticket.jacadi.com',
-	'bonjour@ferflex.fr',
-	'news@email.arthur.fr',
-	'mailgun@mg.welmo.fr',
-	'info@kiddiprint.com',
-	'confirmation-commande@amazon.fr',
-	'shipment-tracking@amazon.fr',
-	'order-update@amazon.fr',
-	'noreply@audible.fr',
-	'team@email.remarkable.com',
-	'contact@thepihut.com',
-	'email.campaign@sg.booking.com',
-	'info@e.sixt.fr',
-	'noreply@komoot.de',
-}
+-- Archive messages by year
+function archive_by_year(message_set)
+	local messages_by_year = {}
+	
+	for _, msg in ipairs(message_set) do
+		local year = get_message_year(msg)
+		if not messages_by_year[year] then
+			messages_by_year[year] = Set {}
+		end
+		messages_by_year[year] = messages_by_year[year] + Set {msg}
+	end
+	
+	for year, msgs in pairs(messages_by_year) do
+		local folder_name = 'Archive/' .. year
+		account:create_mailbox(folder_name)
+		msgs:move_messages(account[folder_name])
+	end
+end
 
-local toarchive = {
-	'pragmaticengineer@substack.com',
-	'pragmaticengineer+deepdives@substack.com',
-	'newsletter@farnamstreetblog.com',
-	'james@jamesclear.com',
-	'bloodinthemachine@substack.com',
-	'peter@golangweekly.com',
-	'learn@semaphore.io',
-	'todoist@substack.com',
-	'hello@readwise.io',
-	'newsletter@quotidien.fr',
-	'support@pragprog.com',
-	'noreply@7digital.com',
-}
-filter_by_senders(messages, todelete, 'delete')
-filter_by_senders(messages, toarchive, 'archive')
+-- Extract year from email date header
+function get_message_year(message)
+	local mbox, uid = table.unpack(message)
+	local date_header = mbox[uid]:fetch_header()
+	local year = date_header:match("Date:.-(%d%d%d%d)")
+	if not year then
+		year = os.date("%Y")
+	end
+	return year
+end
+
+----------
+-- Main Filtering Logic
+----------
+
+-- Load rules from private repository
+local rules_dir = os.getenv("HOME") .. "/.local/share/imapfilter-rules"
+
+print("Loading rules from: " .. rules_dir)
+
+local delete_rules = load_rules_from_file(rules_dir .. "/delete.txt")
+local receipts_rules = load_rules_from_file(rules_dir .. "/receipts.txt")
+local newsletters_rules = load_rules_from_file(rules_dir .. "/newsletters.txt")
+local archive_rules = load_rules_from_file(rules_dir .. "/archive.txt")
+
+-- Get all messages in INBOX
+messages = account['INBOX']:select_all()
+
+-- Apply filters
+print("Applying delete rules...")
+apply_rules(messages, delete_rules, 'delete')
+
+print("Applying receipts rules...")
+apply_rules(messages, receipts_rules, 'move', 'Receipts')
+
+print("Applying newsletters rules...")
+apply_rules(messages, newsletters_rules, 'move', 'Newsletters')
+
+print("Applying archive rules...")
+apply_rules(messages, archive_rules, 'archive')
+
+-- GitHub notifications (existing rule)
+print("Moving GitHub notifications...")
+github = messages:contain_from('notifications@github.com')
+account:create_mailbox('GitHub')
+github:move_messages(account['GitHub'])
+
+print("Filtering complete!")