heathrow 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +58 -0
  3. data/README.md +205 -0
  4. data/bin/heathrow +42 -0
  5. data/bin/heathrowd +283 -0
  6. data/docs/ARCHITECTURE.md +1172 -0
  7. data/docs/DATABASE_SCHEMA.md +685 -0
  8. data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
  9. data/docs/DISCORD_SETUP.md +142 -0
  10. data/docs/GMAIL_OAUTH_SETUP.md +120 -0
  11. data/docs/PLUGIN_SYSTEM.md +1370 -0
  12. data/docs/PROJECT_PLAN.md +1022 -0
  13. data/docs/README.md +417 -0
  14. data/docs/REDDIT_SETUP.md +174 -0
  15. data/docs/REPLY_FORWARD.md +182 -0
  16. data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
  17. data/heathrow.gemspec +34 -0
  18. data/heathrowd.service +21 -0
  19. data/img/heathrow.svg +95 -0
  20. data/img/rss_threaded.png +0 -0
  21. data/img/sources.png +0 -0
  22. data/lib/heathrow/address_book.rb +42 -0
  23. data/lib/heathrow/config.rb +332 -0
  24. data/lib/heathrow/database.rb +731 -0
  25. data/lib/heathrow/database_new.rb +392 -0
  26. data/lib/heathrow/event_bus.rb +175 -0
  27. data/lib/heathrow/logger.rb +122 -0
  28. data/lib/heathrow/message.rb +176 -0
  29. data/lib/heathrow/message_composer.rb +399 -0
  30. data/lib/heathrow/message_organizer.rb +774 -0
  31. data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
  32. data/lib/heathrow/notmuch.rb +45 -0
  33. data/lib/heathrow/oauth2_smtp.rb +254 -0
  34. data/lib/heathrow/plugin/base.rb +212 -0
  35. data/lib/heathrow/plugin_manager.rb +141 -0
  36. data/lib/heathrow/poller.rb +93 -0
  37. data/lib/heathrow/smtp_sender.rb +204 -0
  38. data/lib/heathrow/source.rb +39 -0
  39. data/lib/heathrow/sources/base.rb +74 -0
  40. data/lib/heathrow/sources/discord.rb +357 -0
  41. data/lib/heathrow/sources/gmail.rb +294 -0
  42. data/lib/heathrow/sources/imap.rb +198 -0
  43. data/lib/heathrow/sources/instagram.rb +307 -0
  44. data/lib/heathrow/sources/instagram_fetch.py +101 -0
  45. data/lib/heathrow/sources/instagram_send.py +55 -0
  46. data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
  47. data/lib/heathrow/sources/maildir.rb +606 -0
  48. data/lib/heathrow/sources/messenger.rb +212 -0
  49. data/lib/heathrow/sources/messenger_fetch.js +297 -0
  50. data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
  51. data/lib/heathrow/sources/messenger_send.js +32 -0
  52. data/lib/heathrow/sources/messenger_send.py +100 -0
  53. data/lib/heathrow/sources/reddit.rb +461 -0
  54. data/lib/heathrow/sources/rss.rb +299 -0
  55. data/lib/heathrow/sources/slack.rb +375 -0
  56. data/lib/heathrow/sources/source_manager.rb +328 -0
  57. data/lib/heathrow/sources/telegram.rb +498 -0
  58. data/lib/heathrow/sources/webpage.rb +207 -0
  59. data/lib/heathrow/sources/weechat.rb +479 -0
  60. data/lib/heathrow/sources/whatsapp.rb +474 -0
  61. data/lib/heathrow/ui/application.rb +8098 -0
  62. data/lib/heathrow/ui/navigation.rb +8 -0
  63. data/lib/heathrow/ui/panes.rb +8 -0
  64. data/lib/heathrow/ui/source_wizard.rb +567 -0
  65. data/lib/heathrow/ui/threaded_view.rb +780 -0
  66. data/lib/heathrow/ui/views.rb +8 -0
  67. data/lib/heathrow/version.rb +3 -0
  68. data/lib/heathrow/wizards/discord_wizard.rb +193 -0
  69. data/lib/heathrow/wizards/slack_wizard.rb +140 -0
  70. data/lib/heathrow.rb +55 -0
  71. metadata +147 -0
@@ -0,0 +1,606 @@
1
+ require 'mail'
2
+ require 'time'
3
+ require 'timeout'
4
+ require 'fileutils'
5
+ require_relative '../plugin/base'
6
+
7
+ module Heathrow
8
+ module Sources
9
+ # Maildir++ source - Read emails from local Maildir format directories
10
+ #
11
+ # Supports Maildir++ subfolder format where subfolders are dot-prefixed
12
+ # directories (e.g., .Personal, .Work.Archive) each containing
13
+ # their own cur/new/tmp structure.
14
+ #
15
+ # Configuration:
16
+ # {
17
+ # "maildir_path": "/home/user/Maildir",
18
+ # "max_age_days": 30,
19
+ # "include_folders": ["Geir", "AA"], # Optional whitelist
20
+ # "exclude_folders": ["Trash", "Spam"] # Optional blacklist
21
+ # }
22
+ #
23
+ class Maildir < Heathrow::Plugin::Base
24
+ def initialize(source, logger: nil, event_bus: nil)
25
+ super(source, logger: logger, event_bus: event_bus)
26
+ @maildir_path = @config['maildir_path'] || File.join(Dir.home, 'Maildir')
27
+ @max_age_days = @config['max_age_days']
28
+ @include_folders = @config['include_folders']
29
+ @exclude_folders = @config['exclude_folders']
30
+ @capabilities = ['read', 'send']
31
+
32
+ validate_maildir_path!
33
+ end
34
+
35
+ # Discover all Maildir++ folders
36
+ def discover_folders
37
+ folders = [{ name: 'INBOX', path: @maildir_path }]
38
+ Dir.glob(File.join(@maildir_path, '.*')).sort.each do |dir|
39
+ basename = File.basename(dir)
40
+ next if basename == '.' || basename == '..'
41
+ next unless File.directory?(dir)
42
+ next unless File.directory?(File.join(dir, 'cur')) || File.directory?(File.join(dir, 'new'))
43
+ folder_name = basename.sub(/^\./, '')
44
+ folders << { name: folder_name, path: dir }
45
+ end
46
+ folders
47
+ end
48
+
49
+ # List all folder names
50
+ def list_folders
51
+ discover_folders.map { |f| f[:name] }
52
+ end
53
+
54
+ def fetch_messages
55
+ messages = []
56
+ folders = apply_folder_filters(discover_folders)
57
+
58
+ log_info("Scanning #{folders.size} Maildir++ folders", path: @maildir_path)
59
+
60
+ folders.each do |folder|
61
+ ['cur', 'new'].each do |subdir|
62
+ folder_path = File.join(folder[:path], subdir)
63
+ next unless Dir.exist?(folder_path)
64
+
65
+ Dir.glob(File.join(folder_path, '*')).each do |file_path|
66
+ next if File.directory?(file_path)
67
+
68
+ begin
69
+ msg = parse_maildir_file(file_path, folder[:name])
70
+ messages << msg if msg && (!@max_age_days || msg[:timestamp] > cutoff_time)
71
+ rescue => e
72
+ log_error("Error parsing Maildir file #{file_path}", e)
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ log_info("Fetched #{messages.size} messages from #{folders.size} folders", path: @maildir_path)
79
+ publish_event('maildir.fetched', count: messages.size, path: @maildir_path)
80
+
81
+ messages
82
+ end
83
+
84
+ def setup_wizard
85
+ [
86
+ {
87
+ key: 'maildir_path',
88
+ prompt: 'Enter path to your Maildir folder:',
89
+ type: 'text',
90
+ default: File.join(Dir.home, 'Maildir'),
91
+ required: true
92
+ },
93
+ {
94
+ key: 'max_age_days',
95
+ prompt: 'Only sync messages from last N days (blank for all):',
96
+ type: 'number',
97
+ required: false
98
+ }
99
+ ]
100
+ end
101
+
102
+ def validate_config
103
+ unless @config['maildir_path']
104
+ return [false, "maildir_path is required"]
105
+ end
106
+
107
+ unless Dir.exist?(@config['maildir_path'])
108
+ return [false, "Maildir directory does not exist: #{@config['maildir_path']}"]
109
+ end
110
+
111
+ [true, nil]
112
+ end
113
+
114
+ # Incremental sync: compare disk files with DB for a single folder
115
+ def sync_folder(db, source_id, folder_name, folder_path)
116
+ # 1. List all files on disk (cur/ + new/)
117
+ disk_files = {} # base_id => full_path
118
+ ['cur', 'new'].each do |subdir|
119
+ dir = File.join(folder_path, subdir)
120
+ next unless Dir.exist?(dir)
121
+ Dir.foreach(dir) do |f|
122
+ next if f.start_with?('.')
123
+ path = File.join(dir, f)
124
+ next if File.directory?(path)
125
+ base_id = f.split(':2,', 2).first
126
+ disk_files[base_id] = path
127
+ end
128
+ end
129
+
130
+ # 2. Get DB index for this folder
131
+ db_index = db.get_folder_index(source_id, folder_name)
132
+
133
+ new_base_ids = disk_files.keys - db_index.keys
134
+ deleted_base_ids = db_index.keys - disk_files.keys
135
+ changed_base_ids = disk_files.keys & db_index.keys
136
+
137
+ # Skip if nothing changed (return false)
138
+ return false if new_base_ids.empty? && deleted_base_ids.empty? && changed_base_ids.all? { |bid|
139
+ flags = self.class.parse_maildir_flags(disk_files[bid])
140
+ db_row = db_index[bid]
141
+ flags[:seen] == (db_row[:read] == 1) &&
142
+ flags[:flagged] == (db_row[:starred] == 1) &&
143
+ flags[:replied] == (db_row[:replied] == 1) &&
144
+ File.basename(disk_files[bid]) == db_row[:external_id]
145
+ }
146
+
147
+ structural_change = !new_base_ids.empty? || !deleted_base_ids.empty?
148
+
149
+ # Batch all writes in one transaction (single lock acquisition)
150
+ db.transaction do
151
+ # 3. New files (on disk, not in DB) — parse and insert
152
+ new_base_ids.each do |base_id|
153
+ begin
154
+ msg = parse_maildir_file(disk_files[base_id], folder_name)
155
+ next unless msg
156
+ msg[:source_id] = source_id
157
+ db.insert_message(msg)
158
+ rescue => e
159
+ log_error("Error parsing new Maildir file #{disk_files[base_id]}", e)
160
+ end
161
+ end
162
+
163
+ # 4. Deleted files (in DB, not on disk) — remove
164
+ unless deleted_base_ids.empty?
165
+ ids_to_delete = deleted_base_ids.map { |bid| db_index[bid][:id] }
166
+ db.delete_messages_by_ids(ids_to_delete)
167
+ end
168
+
169
+ # 5. Flag changes (both exist, but flags differ)
170
+ changed_base_ids.each do |base_id|
171
+ flags = self.class.parse_maildir_flags(disk_files[base_id])
172
+ db_row = db_index[base_id]
173
+ if flags[:seen] != (db_row[:read] == 1) || flags[:flagged] != (db_row[:starred] == 1) || flags[:replied] != (db_row[:replied] == 1)
174
+ db.execute("UPDATE messages SET read = ?, starred = ?, replied = ? WHERE id = ?",
175
+ flags[:seen] ? 1 : 0, flags[:flagged] ? 1 : 0, flags[:replied] ? 1 : 0, db_row[:id])
176
+ end
177
+ # Update external_id and metadata if filename changed
178
+ current_filename = File.basename(disk_files[base_id])
179
+ if current_filename != db_row[:external_id]
180
+ begin
181
+ db.execute("UPDATE messages SET external_id = ? WHERE id = ?",
182
+ current_filename, db_row[:id])
183
+ db.execute("UPDATE messages SET metadata = json_set(metadata, '$.maildir_file', ?) WHERE id = ?",
184
+ disk_files[base_id], db_row[:id])
185
+ rescue SQLite3::ConstraintException
186
+ # Duplicate — skip
187
+ end
188
+ end
189
+ end
190
+ end
191
+ # Only return true for new/deleted messages (structural changes).
192
+ # Flag-only changes are already reflected in the UI and don't need
193
+ # a view refresh that could shift the selected index.
194
+ structural_change
195
+ end
196
+
197
+ # Full sync across all folders (with include/exclude filters).
198
+ # Returns true if any folder had changes.
199
+ # Yields between folders so caller can pause/abort.
200
+ def sync_all(db, source_id)
201
+ changed = false
202
+ apply_folder_filters(discover_folders).each do |f|
203
+ changed = true if sync_folder(db, source_id, f[:name], f[:path])
204
+ yield if block_given?
205
+ end
206
+ changed
207
+ end
208
+
209
+ def health_check
210
+ begin
211
+ unless Dir.exist?(@maildir_path)
212
+ return [false, "Maildir directory not found: #{@maildir_path}"]
213
+ end
214
+
215
+ folder_count = discover_folders.size
216
+ [true, "OK - #{@maildir_path} (#{folder_count} folders)"]
217
+ rescue => e
218
+ [false, e.message]
219
+ end
220
+ end
221
+
222
+ # Send an email by piping RFC822 message through the SMTP script.
223
+ # Returns { success: bool, message: string }
224
+ def send_message(to, subject, body, in_reply_to = nil,
225
+ from: nil, cc: nil, bcc: nil, reply_to: nil,
226
+ extra_headers: nil, smtp_command: nil, attachments: nil)
227
+ msg_from = from || @config['from']
228
+ return { success: false, message: "No From address configured" } unless msg_from
229
+ return { success: false, message: "No SMTP command configured" } unless smtp_command
230
+
231
+ from_addr = msg_from[/<([^>]+)>/, 1] || msg_from
232
+
233
+ # Build RFC822 email
234
+ msg_to = to; msg_cc = cc; msg_bcc = bcc
235
+ msg_subj = subject; msg_body = body; msg_reply_to = reply_to
236
+ msg_attachments = attachments
237
+ if msg_attachments && !msg_attachments.empty?
238
+ # Multipart message with attachments
239
+ mail = Mail.new do
240
+ from msg_from
241
+ to msg_to
242
+ cc msg_cc if msg_cc && !msg_cc.empty?
243
+ bcc msg_bcc if msg_bcc && !msg_bcc.empty?
244
+ reply_to msg_reply_to if msg_reply_to && !msg_reply_to.empty?
245
+ subject msg_subj
246
+ end
247
+ mail.text_part = Mail::Part.new do
248
+ content_type 'text/plain; charset=UTF-8'
249
+ body msg_body
250
+ end
251
+ msg_attachments.each do |filepath|
252
+ mail.add_file(filepath)
253
+ end
254
+ else
255
+ mail = Mail.new do
256
+ from msg_from
257
+ to msg_to
258
+ cc msg_cc if msg_cc && !msg_cc.empty?
259
+ bcc msg_bcc if msg_bcc && !msg_bcc.empty?
260
+ reply_to msg_reply_to if msg_reply_to && !msg_reply_to.empty?
261
+ subject msg_subj
262
+ content_type 'text/plain; charset=UTF-8'
263
+ body msg_body
264
+ end
265
+ end
266
+ mail.in_reply_to = in_reply_to if in_reply_to
267
+ mail.message_id = Mail::MessageIdField.new.message_id
268
+
269
+ if extra_headers
270
+ extra_headers.each { |k, v| mail[k] = v }
271
+ end
272
+
273
+ # Suppress STDERR from mail gem and SMTP script (charset warnings etc.)
274
+ # Strip CR from CRLF — mail gem outputs RFC822 CRLF but Maildir expects LF
275
+ mail_str = suppress_stderr { mail.to_s }.gsub("\r\n", "\n")
276
+
277
+ # Collect all envelope recipients (bare email addresses for SMTP)
278
+ all_recipients = Array(to).flat_map { |r| r.split(',').map(&:strip) }
279
+ all_recipients += Array(cc).flat_map { |r| r.split(',').map(&:strip) } if cc
280
+ all_recipients += Array(bcc).flat_map { |r| r.split(',').map(&:strip) } if bcc
281
+ all_recipients.map! { |r| r[/<([^>]+)>/, 1] || r }
282
+
283
+ # Pipe through SMTP script (same interface as mutt's sendmail)
284
+ cmd_args = [smtp_command, '-f', from_addr, '--'] + all_recipients
285
+ stderr_output = ""
286
+ require 'open3'
287
+ status = nil
288
+ Open3.popen3(*cmd_args) do |stdin, stdout, stderr, wait_thr|
289
+ stdin.write(mail_str)
290
+ stdin.close
291
+ stderr_output = stderr.read
292
+ status = wait_thr.value
293
+ end
294
+
295
+ if status&.success?
296
+ save_to_sent(mail_str)
297
+ { success: true, message: "Message sent to #{to}" }
298
+ else
299
+ err_detail = stderr_output.strip.lines.last(2).join(' ').strip
300
+ err_detail = "exit #{status&.exitstatus || '?'}" if err_detail.empty?
301
+ { success: false, message: "SMTP failed: #{err_detail}" }
302
+ end
303
+ rescue => e
304
+ { success: false, message: "Send failed: #{e.message}" }
305
+ end
306
+
307
+ # Parse Maildir flags from filename
308
+ # Returns hash: {seen: bool, flagged: bool, replied: bool, trashed: bool}
309
+ def self.parse_maildir_flags(filename)
310
+ basename = File.basename(filename)
311
+ flags = { seen: false, flagged: false, replied: false, trashed: false, draft: false, passed: false }
312
+ if basename.include?(':2,')
313
+ flag_str = basename.split(':2,', 2).last
314
+ flags[:draft] = flag_str.include?('D')
315
+ flags[:flagged] = flag_str.include?('F')
316
+ flags[:passed] = flag_str.include?('P')
317
+ flags[:replied] = flag_str.include?('R')
318
+ flags[:seen] = flag_str.include?('S')
319
+ flags[:trashed] = flag_str.include?('T')
320
+ end
321
+ flags
322
+ end
323
+
324
+ # Rename a Maildir file to add or remove a flag character
325
+ # Returns the new file path
326
+ def self.rename_with_flag(file_path, flag_char, add: true)
327
+ return file_path unless File.exist?(file_path)
328
+
329
+ dir = File.dirname(file_path)
330
+ basename = File.basename(file_path)
331
+
332
+ if basename.include?(':2,')
333
+ prefix, flags = basename.split(':2,', 2)
334
+ if add
335
+ flags = (flags.chars + [flag_char]).uniq.sort.join
336
+ else
337
+ flags = flags.delete(flag_char)
338
+ end
339
+ new_name = "#{prefix}:2,#{flags}"
340
+ else
341
+ new_name = add ? "#{basename}:2,#{flag_char}" : basename
342
+ end
343
+
344
+ # Maildir spec: messages with flags must live in cur/, not new/
345
+ target_dir = if dir.end_with?('/new')
346
+ dir.sub(/\/new\z/, '/cur')
347
+ else
348
+ dir
349
+ end
350
+ new_path = File.join(target_dir, new_name)
351
+ if file_path != new_path
352
+ File.rename(file_path, new_path)
353
+ end
354
+ new_path
355
+ end
356
+
357
+ # Sync read status to Maildir file (add/remove S flag)
358
+ def self.sync_read_flag(file_path, is_read)
359
+ rename_with_flag(file_path, 'S', add: is_read)
360
+ end
361
+
362
+ # Sync star/flagged status to Maildir file (add/remove F flag)
363
+ def self.sync_flagged(file_path, is_flagged)
364
+ rename_with_flag(file_path, 'F', add: is_flagged)
365
+ end
366
+
367
+ # Sync trashed status to Maildir file (add/remove T flag)
368
+ def self.sync_trashed(file_path)
369
+ rename_with_flag(file_path, 'T', add: true)
370
+ end
371
+
372
+ # Move a message file to a different folder
373
+ # Returns new file path
374
+ def self.move_to_folder(file_path, maildir_root, dest_folder_name)
375
+ return nil unless File.exist?(file_path)
376
+
377
+ # Determine destination directory
378
+ if dest_folder_name == 'INBOX'
379
+ dest_dir = File.join(maildir_root, 'cur')
380
+ else
381
+ dest_dir = File.join(maildir_root, ".#{dest_folder_name}", 'cur')
382
+ end
383
+
384
+ # Create destination if needed
385
+ FileUtils.mkdir_p(dest_dir)
386
+ tmp_dir = File.join(File.dirname(dest_dir), 'tmp')
387
+ new_dir = File.join(File.dirname(dest_dir), 'new')
388
+ FileUtils.mkdir_p(tmp_dir)
389
+ FileUtils.mkdir_p(new_dir)
390
+
391
+ new_path = File.join(dest_dir, File.basename(file_path))
392
+ File.rename(file_path, new_path)
393
+ new_path
394
+ end
395
+
396
+ private
397
+
398
+ # Apply include/exclude folder filters
399
+ def apply_folder_filters(folders)
400
+ if @include_folders && !@include_folders.empty?
401
+ folders.select! { |f| f[:name] == 'INBOX' || @include_folders.any? { |inc| f[:name].start_with?(inc) } }
402
+ end
403
+ if @exclude_folders && !@exclude_folders.empty?
404
+ folders.reject! { |f| @exclude_folders.any? { |exc| f[:name].start_with?(exc) } }
405
+ end
406
+ folders
407
+ end
408
+
409
+ def validate_maildir_path!
410
+ unless Dir.exist?(@maildir_path)
411
+ log_error("Maildir directory does not exist", path: @maildir_path)
412
+ raise "Maildir directory not found: #{@maildir_path}"
413
+ end
414
+ end
415
+
416
+ def cutoff_time
417
+ return 0 unless @max_age_days
418
+ Time.now.to_i - (@max_age_days * 86400)
419
+ end
420
+
421
+ def parse_maildir_file(file_path, folder_name)
422
+ Timeout.timeout(5) do
423
+ _parse_maildir_file_inner(file_path, folder_name)
424
+ end
425
+ rescue Timeout::Error
426
+ log_error("Timeout parsing #{file_path}", nil)
427
+ nil
428
+ end
429
+
430
+ def _parse_maildir_file_inner(file_path, folder_name)
431
+ raw_email = File.binread(file_path)
432
+ mail = Mail.new(raw_email)
433
+
434
+ external_id = File.basename(file_path)
435
+
436
+ # Parse flags from filename
437
+ flags = self.class.parse_maildir_flags(file_path)
438
+ is_read = flags[:seen]
439
+ is_starred = flags[:flagged]
440
+
441
+ # Extract recipients
442
+ recipients = []
443
+ recipients << mail.to if mail.to
444
+ recipients = recipients.flatten.compact
445
+
446
+ cc = mail.cc ? Array(mail.cc).flatten.compact : []
447
+
448
+ # Parse timestamp
449
+ timestamp = mail.date ? mail.date.to_time.to_i : File.mtime(file_path).to_i
450
+
451
+ # Extract sender info
452
+ sender = mail.from ? Array(mail.from).first : 'unknown'
453
+ sender_name = extract_sender_name(mail)
454
+
455
+ # Get message content
456
+ content = extract_content(mail)
457
+ html_content = extract_html_content(mail)
458
+
459
+ # Extract attachments info
460
+ attachments = extract_attachments(mail, file_path)
461
+
462
+ # Build metadata
463
+ metadata = {
464
+ 'maildir_folder' => folder_name,
465
+ 'maildir_file' => file_path,
466
+ 'message_id' => mail.message_id,
467
+ 'in_reply_to' => mail.in_reply_to,
468
+ 'references' => mail.references
469
+ }
470
+
471
+ # Store folder name in labels for filtering
472
+ labels = [folder_name]
473
+
474
+ normalize_message(
475
+ external_id: external_id,
476
+ sender: sender,
477
+ sender_name: sender_name,
478
+ recipients: recipients,
479
+ cc: cc,
480
+ subject: mail.subject || '(no subject)',
481
+ content: content,
482
+ html_content: html_content,
483
+ timestamp: timestamp,
484
+ read: is_read,
485
+ starred: is_starred,
486
+ replied: flags[:replied],
487
+ attachments: attachments,
488
+ metadata: metadata,
489
+ labels: labels
490
+ )
491
+ end
492
+
493
+ def extract_sender_name(mail)
494
+ return nil unless mail.from
495
+ if mail[:from] && mail[:from].display_names.any?
496
+ mail[:from].display_names.first
497
+ else
498
+ nil
499
+ end
500
+ end
501
+
502
+ def ensure_utf8(str, charset = nil)
503
+ return str unless str.is_a?(String)
504
+ return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
505
+
506
+ charset = charset&.strip&.downcase
507
+ # Map common charset names
508
+ enc = case charset
509
+ when nil, '', 'us-ascii', 'ascii' then Encoding::ISO_8859_1
510
+ when 'utf-8', 'utf8' then Encoding::UTF_8
511
+ when /iso-?8859-?1/i then Encoding::ISO_8859_1
512
+ when /iso-?8859-?15/i then Encoding::ISO_8859_15
513
+ when /windows-?1252/i, 'cp1252' then Encoding::Windows_1252
514
+ else
515
+ begin
516
+ Encoding.find(charset)
517
+ rescue ArgumentError
518
+ Encoding::ISO_8859_1
519
+ end
520
+ end
521
+
522
+ str.encode(Encoding::UTF_8, enc, invalid: :replace, undef: :replace, replace: '?')
523
+ rescue => e
524
+ str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '?')
525
+ end
526
+
527
+ def extract_content(mail)
528
+ if mail.multipart?
529
+ text_part = mail.text_part
530
+ return ensure_utf8(text_part.decoded, text_part.charset) if text_part
531
+
532
+ html_part = mail.html_part
533
+ return strip_html(ensure_utf8(html_part.decoded, html_part.charset)) if html_part
534
+
535
+ ensure_utf8(mail.body.decoded, mail.charset)
536
+ else
537
+ ensure_utf8(mail.body.decoded, mail.charset)
538
+ end
539
+ rescue => e
540
+ log_error("Error extracting content", e)
541
+ "(Error reading message content)"
542
+ end
543
+
544
+ def extract_html_content(mail)
545
+ if mail.multipart?
546
+ html_part = mail.html_part
547
+ html_part ? ensure_utf8(html_part.decoded, html_part.charset) : nil
548
+ elsif mail.content_type&.include?('text/html')
549
+ ensure_utf8(mail.body.decoded, mail.charset)
550
+ end
551
+ rescue => e
552
+ log_error("Error extracting HTML content", e)
553
+ nil
554
+ end
555
+
556
+ def extract_attachments(mail, file_path)
557
+ return [] unless mail.attachments.any?
558
+
559
+ mail.attachments.map do |attachment|
560
+ {
561
+ 'name' => attachment.filename,
562
+ 'size' => attachment.body.decoded.bytesize,
563
+ 'content_type' => attachment.content_type,
564
+ 'source_file' => file_path
565
+ }
566
+ end
567
+ rescue => e
568
+ log_error("Error extracting attachments", e)
569
+ []
570
+ end
571
+
572
+ def save_to_sent(mail_str)
573
+ # Sent folder from RC config, with strftime expansion
574
+ pattern = Heathrow::Config.instance&.rc('sent_folder', 'Sent.%Y-%m') || 'Sent.%Y-%m'
575
+ folder_name = Time.now.strftime(pattern)
576
+ sent_base = File.join(@maildir_path, ".#{folder_name}")
577
+ sent_dir = File.join(sent_base, 'cur')
578
+ FileUtils.mkdir_p(sent_dir)
579
+ FileUtils.mkdir_p(File.join(sent_base, 'new'))
580
+ FileUtils.mkdir_p(File.join(sent_base, 'tmp'))
581
+
582
+ hostname = suppress_stderr { `hostname`.strip } rescue 'localhost'
583
+ unique_id = "#{Time.now.to_f}.#{$$}.#{hostname}"
584
+ filename = "#{unique_id}:2,S"
585
+ File.write(File.join(sent_dir, filename), mail_str)
586
+ rescue => e
587
+ log_error("Failed to save to Sent folder", e)
588
+ end
589
+
590
+ def suppress_stderr
591
+ old_stderr = $stderr.dup
592
+ $stderr.reopen(File.open(File::NULL, 'w'))
593
+ yield
594
+ ensure
595
+ $stderr.reopen(old_stderr)
596
+ old_stderr.close
597
+ end
598
+
599
+ def strip_html(html)
600
+ html.gsub(/<[^>]+>/, ' ')
601
+ .gsub(/\s+/, ' ')
602
+ .strip
603
+ end
604
+ end
605
+ end
606
+ end