Commit 8e0ef3523ab4

Vincent Demeester <vincent@sbr.pm>
2026-01-14 20:15:58
feat(mail): Implement IMAP IDLE support for imapfilter
- Replace timer-based polling with persistent IDLE connection - Server pushes notifications when new mail arrives instantly - Reduce bandwidth usage and improve mail delivery latency - Auto-restart service on connection failures for reliability Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 1a97b05
Changed files (2)
home/common/services/imapfilter-config.lua
@@ -29,6 +29,16 @@ account = IMAP {
 	ssl = 'tls1.2',
 }
 
+----------
+-- IDLE Configuration
+----------
+-- Wake on any mailbox event (new mail, flag changes, etc.)
+account.wakeonany = false  -- Only wake on new mail
+-- Re-enter IDLE after connection recovery
+account.reenter = true
+-- Keepalive interval (minutes) before re-issuing IDLE command
+account.keepalive = 29
+
 ----------
 -- Rule File Parser
 ----------
@@ -221,39 +231,64 @@ end
 -- Load rules from private repository
 local rules_dir = os.getenv("HOME") .. "/.local/share/imapfilter-rules"
 
-print("Loading rules from: " .. rules_dir)
+-- Function to run all filtering rules
+function run_filters()
+	print("\n=== Starting filter run at " .. os.date("%Y-%m-%d %H:%M:%S") .. " ===")
+	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")
+	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()
-print(string.format("Processing %d messages from INBOX", #messages))
+	-- Get all messages in INBOX
+	messages = account['INBOX']:select_all()
+	print(string.format("Processing %d messages from INBOX", #messages))
 
--- Apply filters
-print("Applying delete rules...")
-apply_rules(messages, delete_rules, 'delete')
+	-- 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 receipts rules...")
+	apply_rules(messages, receipts_rules, 'move', 'Receipts')
 
-print("Applying newsletters rules...")
-apply_rules(messages, newsletters_rules, 'move', 'Newsletters')
+	print("Applying newsletters rules...")
+	apply_rules(messages, newsletters_rules, 'move', 'Newsletters')
 
-print("Applying archive rules...")
-apply_rules(messages, archive_rules, 'archive')
+	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')
-if #github > 0 then
-	print(string.format("  → Matched %d message(s)", #github))
-	account:create_mailbox('_trackers/github')
-	github:move_messages(account['_trackers/github'])
-else
-	print("  → No matches")
+	-- GitHub notifications (existing rule)
+	print("Moving GitHub notifications...")
+	github = messages:contain_from('notifications@github.com')
+	if #github > 0 then
+		print(string.format("  → Matched %d message(s)", #github))
+		account:create_mailbox('_trackers/github')
+		github:move_messages(account['_trackers/github'])
+	else
+		print("  → No matches")
+	end
+
+	print("Filtering complete!")
 end
 
-print("Filtering complete!")
+----------
+-- IDLE Loop
+----------
+
+-- Run initial filter
+run_filters()
+
+-- Enter IDLE loop
+print("\n=== Entering IDLE mode - waiting for new mail ===")
+while true do
+	-- Enter IDLE and wait for server notification
+	account['INBOX']:enter_idle()
+
+	-- When we wake up, run the filters
+	print("\n=== IDLE interrupted - new mail detected ===")
+	run_filters()
+
+	-- Go back to IDLE
+	print("\n=== Returning to IDLE mode ===")
+end
home/common/services/imapfilter.nix
@@ -3,16 +3,16 @@
   # Enable imapfilter package
   home.packages = [ pkgs.imapfilter ];
 
-  # Create a systemd user service for imapfilter
+  # Create a systemd user service for imapfilter with IDLE support
   systemd.user.services.imapfilter = {
     Unit = {
-      Description = "imapfilter - IMAP mail filtering utility";
+      Description = "imapfilter - IMAP mail filtering with IDLE support";
       After = [ "network-online.target" ];
       Wants = [ "network-online.target" ];
     };
 
     Service = {
-      Type = "oneshot";
+      Type = "simple";
       # Update rules from private repository before filtering
       # Configure git to use SSH without agent (direct key access)
       Environment = [
@@ -20,27 +20,15 @@
       ];
       ExecStartPre = "${pkgs.git}/bin/git -C %h/.local/share/imapfilter-rules pull --quiet";
       # Password is read from agenix secret file in Lua config
-      # Verbose mode enabled for testing new filters
+      # Runs continuously with IDLE mode - waits for server notifications
       ExecStart = "${pkgs.imapfilter}/bin/imapfilter -v -c ${./imapfilter-config.lua}";
-      # Standard mode (use after testing is complete)
-      # ExecStart = "${pkgs.imapfilter}/bin/imapfilter -c ${./imapfilter-config.lua}";
-    };
-  };
-
-  # Create a systemd timer to run every 2 hours
-  systemd.user.timers.imapfilter = {
-    Unit = {
-      Description = "imapfilter timer - runs every 2 hours";
-    };
-
-    Timer = {
-      OnBootSec = "5min";
-      OnUnitActiveSec = "2h";
-      Unit = "imapfilter.service";
+      # Restart on failure to maintain persistent connection
+      Restart = "on-failure";
+      RestartSec = "30s";
     };
 
     Install = {
-      WantedBy = [ "timers.target" ];
+      WantedBy = [ "default.target" ];
     };
   };
 }