Class: Heathrow::Sources::Maildir
- Inherits:
-
Plugin::Base
- Object
- Plugin::Base
- Heathrow::Sources::Maildir
- Defined in:
- lib/heathrow/sources/maildir.rb
Overview
Maildir++ source - Read emails from local Maildir format directories
Supports Maildir++ subfolder format where subfolders are dot-prefixed directories (e.g., .Personal, .Work.Archive) each containing their own cur/new/tmp structure.
Configuration:
{
"maildir_path": "/home/user/Maildir",
"max_age_days": 30,
"include_folders": ["Geir", "AA"], # Optional whitelist
"exclude_folders": ["Trash", "Spam"] # Optional blacklist
}
Instance Attribute Summary
Attributes inherited from Plugin::Base
#config, #event_bus, #logger, #source
Class Method Summary collapse
-
.move_to_folder(file_path, maildir_root, dest_folder_name) ⇒ Object
Move a message file to a different folder Returns new file path.
-
.parse_maildir_flags(filename) ⇒ Object
Parse Maildir flags from filename Returns hash: bool, flagged: bool, replied: bool, trashed: bool.
-
.rename_with_flag(file_path, flag_char, add: true) ⇒ Object
Rename a Maildir file to add or remove a flag character Returns the new file path.
-
.sync_flagged(file_path, is_flagged) ⇒ Object
Sync star/flagged status to Maildir file (add/remove F flag).
-
.sync_read_flag(file_path, is_read) ⇒ Object
Sync read status to Maildir file (add/remove S flag).
-
.sync_trashed(file_path) ⇒ Object
Sync trashed status to Maildir file (add/remove T flag).
Instance Method Summary collapse
-
#discover_folders ⇒ Object
Discover all Maildir++ folders.
- #fetch_messages ⇒ Object
- #health_check ⇒ Object
-
#initialize(source, logger: nil, event_bus: nil) ⇒ Maildir
constructor
A new instance of Maildir.
-
#list_folders ⇒ Object
List all folder names.
-
#send_message(to, subject, body, in_reply_to = nil, from: nil, cc: nil, bcc: nil, reply_to: nil, extra_headers: nil, smtp_command: nil, attachments: nil) ⇒ Object
Send an email by piping RFC822 message through the SMTP script.
- #setup_wizard ⇒ Object
-
#sync_all(db, source_id) ⇒ Object
Full sync across all folders (with include/exclude filters).
-
#sync_folder(db, source_id, folder_name, folder_path) ⇒ Object
Incremental sync: compare disk files with DB for a single folder.
- #validate_config ⇒ Object
Methods inherited from Plugin::Base
#can_delete?, #can_mark_read?, #can_reply?, #capabilities, #delete_message, #mark_read, #metadata, #supports_attachments?, #supports_real_time?, #supports_threads?
Constructor Details
#initialize(source, logger: nil, event_bus: nil) ⇒ Maildir
Returns a new instance of Maildir.
24 25 26 27 28 29 30 31 32 33 |
# File 'lib/heathrow/sources/maildir.rb', line 24 def initialize(source, logger: nil, event_bus: nil) super(source, logger: logger, event_bus: event_bus) @maildir_path = @config['maildir_path'] || File.join(Dir.home, 'Maildir') @max_age_days = @config['max_age_days'] @include_folders = @config['include_folders'] @exclude_folders = @config['exclude_folders'] @capabilities = ['read', 'send'] validate_maildir_path! end |
Class Method Details
.move_to_folder(file_path, maildir_root, dest_folder_name) ⇒ Object
Move a message file to a different folder Returns new file path
382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 |
# File 'lib/heathrow/sources/maildir.rb', line 382 def self.move_to_folder(file_path, maildir_root, dest_folder_name) return nil unless File.exist?(file_path) # Determine destination directory if dest_folder_name == 'INBOX' dest_dir = File.join(maildir_root, 'cur') else dest_dir = File.join(maildir_root, ".#{dest_folder_name}", 'cur') end # Create destination if needed FileUtils.mkdir_p(dest_dir) tmp_dir = File.join(File.dirname(dest_dir), 'tmp') new_dir = File.join(File.dirname(dest_dir), 'new') FileUtils.mkdir_p(tmp_dir) FileUtils.mkdir_p(new_dir) new_path = File.join(dest_dir, File.basename(file_path)) File.rename(file_path, new_path) new_path end |
.parse_maildir_flags(filename) ⇒ Object
Parse Maildir flags from filename Returns hash: bool, flagged: bool, replied: bool, trashed: bool
317 318 319 320 321 322 323 324 325 326 327 328 329 330 |
# File 'lib/heathrow/sources/maildir.rb', line 317 def self.parse_maildir_flags(filename) basename = File.basename(filename) flags = { seen: false, flagged: false, replied: false, trashed: false, draft: false, passed: false } if basename.include?(':2,') flag_str = basename.split(':2,', 2).last flags[:draft] = flag_str.include?('D') flags[:flagged] = flag_str.include?('F') flags[:passed] = flag_str.include?('P') flags[:replied] = flag_str.include?('R') flags[:seen] = flag_str.include?('S') flags[:trashed] = flag_str.include?('T') end flags end |
.rename_with_flag(file_path, flag_char, add: true) ⇒ Object
Rename a Maildir file to add or remove a flag character Returns the new file path
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 |
# File 'lib/heathrow/sources/maildir.rb', line 334 def self.rename_with_flag(file_path, flag_char, add: true) return file_path unless File.exist?(file_path) dir = File.dirname(file_path) basename = File.basename(file_path) if basename.include?(':2,') prefix, flags = basename.split(':2,', 2) if add flags = (flags.chars + [flag_char]).uniq.sort.join else flags = flags.delete(flag_char) end new_name = "#{prefix}:2,#{flags}" else new_name = add ? "#{basename}:2,#{flag_char}" : basename end # Maildir spec: messages with flags must live in cur/, not new/ target_dir = if dir.end_with?('/new') dir.sub(/\/new\z/, '/cur') else dir end new_path = File.join(target_dir, new_name) if file_path != new_path File.rename(file_path, new_path) end new_path end |
.sync_flagged(file_path, is_flagged) ⇒ Object
Sync star/flagged status to Maildir file (add/remove F flag)
371 372 373 |
# File 'lib/heathrow/sources/maildir.rb', line 371 def self.sync_flagged(file_path, is_flagged) rename_with_flag(file_path, 'F', add: is_flagged) end |
.sync_read_flag(file_path, is_read) ⇒ Object
Sync read status to Maildir file (add/remove S flag)
366 367 368 |
# File 'lib/heathrow/sources/maildir.rb', line 366 def self.sync_read_flag(file_path, is_read) rename_with_flag(file_path, 'S', add: is_read) end |
.sync_trashed(file_path) ⇒ Object
Sync trashed status to Maildir file (add/remove T flag)
376 377 378 |
# File 'lib/heathrow/sources/maildir.rb', line 376 def self.sync_trashed(file_path) rename_with_flag(file_path, 'T', add: true) end |
Instance Method Details
#discover_folders ⇒ Object
Discover all Maildir++ folders
36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/heathrow/sources/maildir.rb', line 36 def discover_folders folders = [{ name: 'INBOX', path: @maildir_path }] Dir.glob(File.join(@maildir_path, '.*')).sort.each do |dir| basename = File.basename(dir) next if basename == '.' || basename == '..' next unless File.directory?(dir) next unless File.directory?(File.join(dir, 'cur')) || File.directory?(File.join(dir, 'new')) folder_name = basename.sub(/^\./, '') folders << { name: folder_name, path: dir } end folders end |
#fetch_messages ⇒ Object
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 |
# File 'lib/heathrow/sources/maildir.rb', line 54 def = [] folders = apply_folder_filters(discover_folders) log_info("Scanning #{folders.size} Maildir++ folders", path: @maildir_path) folders.each do |folder| ['cur', 'new'].each do |subdir| folder_path = File.join(folder[:path], subdir) next unless Dir.exist?(folder_path) Dir.glob(File.join(folder_path, '*')).each do |file_path| next if File.directory?(file_path) begin msg = parse_maildir_file(file_path, folder[:name]) << msg if msg && (!@max_age_days || msg[:timestamp] > cutoff_time) rescue => e log_error("Error parsing Maildir file #{file_path}", e) end end end end log_info("Fetched #{.size} messages from #{folders.size} folders", path: @maildir_path) publish_event('maildir.fetched', count: .size, path: @maildir_path) end |
#health_check ⇒ Object
217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/heathrow/sources/maildir.rb', line 217 def health_check begin unless Dir.exist?(@maildir_path) return [false, "Maildir directory not found: #{@maildir_path}"] end folder_count = discover_folders.size [true, "OK - #{@maildir_path} (#{folder_count} folders)"] rescue => e [false, e.] end end |
#list_folders ⇒ Object
List all folder names
50 51 52 |
# File 'lib/heathrow/sources/maildir.rb', line 50 def list_folders discover_folders.map { |f| f[:name] } end |
#send_message(to, subject, body, in_reply_to = nil, from: nil, cc: nil, bcc: nil, reply_to: nil, extra_headers: nil, smtp_command: nil, attachments: nil) ⇒ Object
Send an email by piping RFC822 message through the SMTP script. Returns { success: bool, message: string }
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 |
# File 'lib/heathrow/sources/maildir.rb', line 232 def (to, subject, body, in_reply_to = nil, from: nil, cc: nil, bcc: nil, reply_to: nil, extra_headers: nil, smtp_command: nil, attachments: nil) msg_from = from || @config['from'] return { success: false, message: "No From address configured" } unless msg_from return { success: false, message: "No SMTP command configured" } unless smtp_command from_addr = msg_from[/<([^>]+)>/, 1] || msg_from # Build RFC822 email msg_to = to; msg_cc = cc; msg_bcc = bcc msg_subj = subject; msg_body = body; msg_reply_to = reply_to = if && !.empty? # Multipart message with attachments mail = Mail.new do from msg_from to msg_to cc msg_cc if msg_cc && !msg_cc.empty? bcc msg_bcc if msg_bcc && !msg_bcc.empty? reply_to msg_reply_to if msg_reply_to && !msg_reply_to.empty? subject msg_subj end mail.text_part = Mail::Part.new do content_type 'text/plain; charset=UTF-8' body msg_body end .each do |filepath| mail.add_file(filepath) end else mail = Mail.new do from msg_from to msg_to cc msg_cc if msg_cc && !msg_cc.empty? bcc msg_bcc if msg_bcc && !msg_bcc.empty? reply_to msg_reply_to if msg_reply_to && !msg_reply_to.empty? subject msg_subj content_type 'text/plain; charset=UTF-8' body msg_body end end mail.in_reply_to = in_reply_to if in_reply_to mail. = Mail::MessageIdField.new. if extra_headers extra_headers.each { |k, v| mail[k] = v } end # Suppress STDERR from mail gem and SMTP script (charset warnings etc.) # Strip CR from CRLF — mail gem outputs RFC822 CRLF but Maildir expects LF mail_str = suppress_stderr { mail.to_s }.gsub("\r\n", "\n") # Collect all envelope recipients (bare email addresses for SMTP) all_recipients = Array(to).flat_map { |r| r.split(',').map(&:strip) } all_recipients += Array(cc).flat_map { |r| r.split(',').map(&:strip) } if cc all_recipients += Array(bcc).flat_map { |r| r.split(',').map(&:strip) } if bcc all_recipients.map! { |r| r[/<([^>]+)>/, 1] || r } # Pipe through SMTP script (same interface as mutt's sendmail) cmd_args = [smtp_command, '-f', from_addr, '--'] + all_recipients stderr_output = "" require 'open3' status = nil Open3.popen3(*cmd_args) do |stdin, stdout, stderr, wait_thr| stdin.write(mail_str) stdin.close stderr_output = stderr.read status = wait_thr.value end if status&.success? save_to_sent(mail_str) { success: true, message: "Message sent to #{to}" } else err_detail = stderr_output.strip.lines.last(2).join(' ').strip err_detail = "exit #{status&.exitstatus || '?'}" if err_detail.empty? { success: false, message: "SMTP failed: #{err_detail}" } end rescue => e { success: false, message: "Send failed: #{e.}" } end |
#setup_wizard ⇒ Object
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/heathrow/sources/maildir.rb', line 84 def setup_wizard [ { key: 'maildir_path', prompt: 'Enter path to your Maildir folder:', type: 'text', default: File.join(Dir.home, 'Maildir'), required: true }, { key: 'max_age_days', prompt: 'Only sync messages from last N days (blank for all):', type: 'number', required: false } ] end |
#sync_all(db, source_id) ⇒ Object
Full sync across all folders (with include/exclude filters). Returns true if any folder had changes. Yields between folders so caller can pause/abort.
208 209 210 211 212 213 214 215 |
# File 'lib/heathrow/sources/maildir.rb', line 208 def sync_all(db, source_id) changed = false apply_folder_filters(discover_folders).each do |f| changed = true if sync_folder(db, source_id, f[:name], f[:path]) yield if block_given? end changed end |
#sync_folder(db, source_id, folder_name, folder_path) ⇒ Object
Incremental sync: compare disk files with DB for a single folder
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 |
# File 'lib/heathrow/sources/maildir.rb', line 115 def sync_folder(db, source_id, folder_name, folder_path) # 1. List all files on disk (cur/ + new/) disk_files = {} # base_id => full_path ['cur', 'new'].each do |subdir| dir = File.join(folder_path, subdir) next unless Dir.exist?(dir) Dir.foreach(dir) do |f| next if f.start_with?('.') path = File.join(dir, f) next if File.directory?(path) base_id = f.split(':2,', 2).first disk_files[base_id] = path end end # 2. Get DB index for this folder db_index = db.get_folder_index(source_id, folder_name) new_base_ids = disk_files.keys - db_index.keys deleted_base_ids = db_index.keys - disk_files.keys changed_base_ids = disk_files.keys & db_index.keys # Skip if nothing changed (return false) return false if new_base_ids.empty? && deleted_base_ids.empty? && changed_base_ids.all? { |bid| flags = self.class.parse_maildir_flags(disk_files[bid]) db_row = db_index[bid] flags[:seen] == (db_row[:read] == 1) && flags[:flagged] == (db_row[:starred] == 1) && flags[:replied] == (db_row[:replied] == 1) && File.basename(disk_files[bid]) == db_row[:external_id] } structural_change = !new_base_ids.empty? || !deleted_base_ids.empty? # Batch all writes in one transaction (single lock acquisition) db.transaction do # 3. New files (on disk, not in DB) — parse and insert new_base_ids.each do |base_id| begin msg = parse_maildir_file(disk_files[base_id], folder_name) next unless msg msg[:source_id] = source_id db.(msg) rescue => e log_error("Error parsing new Maildir file #{disk_files[base_id]}", e) end end # 4. Deleted files (in DB, not on disk) — remove unless deleted_base_ids.empty? ids_to_delete = deleted_base_ids.map { |bid| db_index[bid][:id] } db.(ids_to_delete) end # 5. Flag changes (both exist, but flags differ) changed_base_ids.each do |base_id| flags = self.class.parse_maildir_flags(disk_files[base_id]) db_row = db_index[base_id] # For replied: DB is authoritative. If DB says replied but disk # doesn't have R flag, add it to disk rather than clearing DB. disk_replied = flags[:replied] db_replied = db_row[:replied] == 1 if db_replied && !disk_replied rename_with_flag(disk_files[base_id], 'R', add: true) disk_replied = true end if flags[:seen] != (db_row[:read] == 1) || flags[:flagged] != (db_row[:starred] == 1) || disk_replied != db_replied db.execute("UPDATE messages SET read = ?, starred = ?, replied = ? WHERE id = ?", flags[:seen] ? 1 : 0, flags[:flagged] ? 1 : 0, disk_replied ? 1 : 0, db_row[:id]) end # Update external_id and metadata if filename changed current_filename = File.basename(disk_files[base_id]) if current_filename != db_row[:external_id] begin db.execute("UPDATE messages SET external_id = ? WHERE id = ?", current_filename, db_row[:id]) db.execute("UPDATE messages SET metadata = json_set(metadata, '$.maildir_file', ?) WHERE id = ?", disk_files[base_id], db_row[:id]) rescue SQLite3::ConstraintException # Duplicate — skip end end end end # Only return true for new/deleted messages (structural changes). # Flag-only changes are already reflected in the UI and don't need # a view refresh that could shift the selected index. structural_change end |
#validate_config ⇒ Object
102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/heathrow/sources/maildir.rb', line 102 def validate_config unless @config['maildir_path'] return [false, "maildir_path is required"] end unless Dir.exist?(@config['maildir_path']) return [false, "Maildir directory does not exist: #{@config['maildir_path']}"] end [true, nil] end |