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,212 @@
1
+ require 'json'
2
+
3
+ module Heathrow
4
+ module Plugin
5
+ # Base class for all Heathrow plugins
6
+ #
7
+ # All communication source plugins (Gmail, Slack, Discord, etc.) should inherit from this class.
8
+ #
9
+ # Required Methods:
10
+ # - fetch_messages: Fetch new messages from the source
11
+ #
12
+ # Optional Methods:
13
+ # - send_message: Send a message (if two-way communication supported)
14
+ # - delete_message: Delete a message
15
+ # - mark_read: Mark message as read on the source
16
+ # - can_reply?: Does this source support replying?
17
+ # - can_delete?: Does this source support deleting messages?
18
+ # - setup_wizard: Configuration wizard steps
19
+ # - validate_config: Validate source configuration
20
+ # - capabilities: List of capabilities this plugin supports
21
+ #
22
+ class Base
23
+ attr_reader :source, :config, :logger, :event_bus
24
+
25
+ def initialize(source, logger: nil, event_bus: nil)
26
+ @source = source
27
+ @config = parse_config(source['config'])
28
+ @logger = logger
29
+ @event_bus = event_bus
30
+ @capabilities = default_capabilities
31
+ end
32
+
33
+ # Fetch new messages from the source
34
+ # Must return an array of message hashes:
35
+ # [{
36
+ # external_id: "msg_123",
37
+ # sender: "user@example.com",
38
+ # sender_name: "John Doe",
39
+ # recipients: ["me@example.com"],
40
+ # subject: "Hello",
41
+ # content: "Message content",
42
+ # timestamp: 1234567890,
43
+ # ...
44
+ # }]
45
+ def fetch_messages
46
+ raise NotImplementedError, "#{self.class} must implement fetch_messages"
47
+ end
48
+
49
+ # Send a message through this source
50
+ # Returns [success, result_or_error]
51
+ def send_message(recipients, subject: nil, content:, **options)
52
+ raise NotImplementedError, "#{self.class} does not support sending messages"
53
+ end
54
+
55
+ # Delete a message (if supported)
56
+ def delete_message(external_id)
57
+ raise NotImplementedError, "#{self.class} does not support deleting messages"
58
+ end
59
+
60
+ # Mark message as read on the source (if supported)
61
+ def mark_read(external_id)
62
+ raise NotImplementedError, "#{self.class} does not support marking messages as read"
63
+ end
64
+
65
+ # Capabilities
66
+ def can_reply?
67
+ @capabilities.include?('write')
68
+ end
69
+
70
+ def can_delete?
71
+ @capabilities.include?('delete')
72
+ end
73
+
74
+ def can_mark_read?
75
+ @capabilities.include?('mark_read')
76
+ end
77
+
78
+ def supports_real_time?
79
+ @capabilities.include?('real_time')
80
+ end
81
+
82
+ def supports_attachments?
83
+ @capabilities.include?('attachments')
84
+ end
85
+
86
+ def supports_threads?
87
+ @capabilities.include?('threads')
88
+ end
89
+
90
+ # Get all capabilities
91
+ def capabilities
92
+ @capabilities
93
+ end
94
+
95
+ # Setup wizard for configuration
96
+ # Returns array of wizard steps:
97
+ # [{
98
+ # key: 'api_key',
99
+ # prompt: 'Enter your API key:',
100
+ # type: 'text',
101
+ # required: true
102
+ # }]
103
+ def setup_wizard
104
+ []
105
+ end
106
+
107
+ # Validate configuration
108
+ # Returns [valid, error_message]
109
+ def validate_config
110
+ [true, nil]
111
+ end
112
+
113
+ # Health check
114
+ # Returns [healthy, status_message]
115
+ def health_check
116
+ begin
117
+ # Default: try to fetch messages as health check
118
+ fetch_messages
119
+ [true, "OK"]
120
+ rescue => e
121
+ [false, e.message]
122
+ end
123
+ end
124
+
125
+ # Get source metadata
126
+ def metadata
127
+ {
128
+ type: self.class.name.split('::').last.downcase,
129
+ capabilities: @capabilities,
130
+ config_keys: @config.keys
131
+ }
132
+ end
133
+
134
+ protected
135
+
136
+ # Parse configuration (handles both JSON string and Hash)
137
+ def parse_config(config)
138
+ return {} if config.nil?
139
+ return config if config.is_a?(Hash)
140
+
141
+ begin
142
+ JSON.parse(config)
143
+ rescue JSON::ParserError
144
+ {}
145
+ end
146
+ end
147
+
148
+ # Default capabilities for a read-only source
149
+ def default_capabilities
150
+ ['read']
151
+ end
152
+
153
+ # Log helper methods
154
+ def log_info(message, context = {})
155
+ @logger&.info(message, context.merge(plugin: self.class.name))
156
+ end
157
+
158
+ def log_error(message, error = nil, context = {})
159
+ ctx = context.merge(plugin: self.class.name)
160
+ ctx[:error] = error if error
161
+ @logger&.error(message, ctx)
162
+ end
163
+
164
+ def log_debug(message, context = {})
165
+ @logger&.debug(message, context.merge(plugin: self.class.name))
166
+ end
167
+
168
+ # Publish event helper
169
+ def publish_event(event_name, data = {})
170
+ @event_bus&.publish(event_name, data.merge(plugin: self.class.name))
171
+ end
172
+
173
+ # Helper: Convert timestamp to Unix timestamp
174
+ def to_unix_timestamp(time)
175
+ case time
176
+ when Integer
177
+ time
178
+ when Time
179
+ time.to_i
180
+ when String
181
+ Time.parse(time).to_i
182
+ else
183
+ Time.now.to_i
184
+ end
185
+ end
186
+
187
+ # Helper: Normalize message data to Heathrow format
188
+ def normalize_message(data)
189
+ {
190
+ external_id: data[:external_id] || data[:id],
191
+ sender: data[:sender] || data[:from],
192
+ sender_name: data[:sender_name] || data[:from_name],
193
+ recipients: Array(data[:recipients] || data[:to]),
194
+ cc: Array(data[:cc]),
195
+ bcc: Array(data[:bcc]),
196
+ subject: data[:subject],
197
+ content: data[:content] || data[:body] || data[:text],
198
+ html_content: data[:html_content] || data[:html],
199
+ timestamp: to_unix_timestamp(data[:timestamp] || data[:created_at] || Time.now),
200
+ read: data[:read],
201
+ starred: data[:starred],
202
+ replied: data[:replied],
203
+ thread_id: data[:thread_id],
204
+ parent_id: data[:parent_id],
205
+ labels: Array(data[:labels] || data[:tags]),
206
+ attachments: Array(data[:attachments]),
207
+ metadata: data[:metadata] || {}
208
+ }
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,141 @@
1
+ # Plugin manager for communication source plugins with error boundaries
2
+ module Heathrow
3
+ class PluginManager
4
+ attr_reader :plugins, :plugin_errors, :logger, :event_bus
5
+
6
+ def initialize(logger: nil, event_bus: nil)
7
+ @plugins = {}
8
+ @plugin_errors = {}
9
+ @plugin_dir = HEATHROW_PLUGINS
10
+ @logger = logger
11
+ @event_bus = event_bus
12
+ ensure_plugin_directory
13
+ load_plugins
14
+ end
15
+
16
+ def ensure_plugin_directory
17
+ require 'fileutils'
18
+ FileUtils.mkdir_p(@plugin_dir) unless Dir.exist?(@plugin_dir)
19
+ end
20
+
21
+ def load_plugins
22
+ # Load built-in plugins
23
+ load_builtin_plugins
24
+
25
+ # Load user plugins from ~/.heathrow/plugins/
26
+ Dir.glob(File.join(@plugin_dir, '*.rb')).each do |plugin_file|
27
+ load_plugin(plugin_file)
28
+ end
29
+
30
+ @logger&.info("PluginManager: Loaded #{@plugins.size} plugin(s)")
31
+ @event_bus&.publish('plugins.loaded', plugin_types: @plugins.keys)
32
+ end
33
+
34
+ def load_builtin_plugins
35
+ # Built-in source plugins will auto-register when required
36
+ # This is handled by the create_source method
37
+ end
38
+
39
+ def load_plugin(plugin_file)
40
+ begin
41
+ require plugin_file
42
+ # Plugin should register itself via PluginManager.register
43
+ @logger&.info("PluginManager: Loaded plugin from #{plugin_file}")
44
+ rescue StandardError => e
45
+ error_msg = "Failed to load plugin #{plugin_file}: #{e.message}"
46
+ @logger&.error(error_msg, error: e)
47
+ @plugin_errors[plugin_file] = e
48
+ end
49
+ end
50
+
51
+ def register(type, plugin_class)
52
+ @plugins[type] = plugin_class
53
+ @logger&.debug("PluginManager: Registered plugin type '#{type}'")
54
+ @event_bus&.publish('plugin.registered', type: type, class: plugin_class.name)
55
+ end
56
+
57
+ def get_plugin(type)
58
+ @plugins[type]
59
+ end
60
+
61
+ def available_types
62
+ @plugins.keys
63
+ end
64
+
65
+ def plugin_registered?(type)
66
+ @plugins.key?(type)
67
+ end
68
+
69
+ def create_source(type, source)
70
+ # Try to load the source module dynamically with error boundary
71
+ begin
72
+ # Handle 'web' as 'webpage' for the module
73
+ module_name = type == 'web' ? 'webpage' : type
74
+ require_relative "sources/#{module_name}"
75
+
76
+ # Get the class
77
+ class_name = module_name.capitalize
78
+ source_class = Heathrow::Sources.const_get(class_name)
79
+
80
+ # Create instance with error boundary
81
+ instance = source_class.new(source)
82
+
83
+ @logger&.info("PluginManager: Created source instance for type '#{type}'", source_id: source['id'])
84
+ @event_bus&.publish('source.created', type: type, source_id: source['id'])
85
+
86
+ instance
87
+ rescue LoadError => e
88
+ error_msg = "Source module not found: #{module_name}"
89
+ @logger&.error(error_msg, error: e, type: type)
90
+ @event_bus&.publish('source.create_failed', type: type, error: e.message)
91
+ nil
92
+ rescue StandardError => e
93
+ error_msg = "Error creating source"
94
+ @logger&.error(error_msg, error: e, type: type)
95
+ @event_bus&.publish('source.create_failed', type: type, error: e.message)
96
+ nil
97
+ end
98
+ end
99
+
100
+ # Execute plugin method with error boundary
101
+ # Returns [success, result_or_error]
102
+ def safe_execute(plugin_instance, method_name, *args)
103
+ begin
104
+ result = plugin_instance.send(method_name, *args)
105
+ @logger&.debug("PluginManager: Executed #{method_name} successfully")
106
+ [true, result]
107
+ rescue => e
108
+ @logger&.error("PluginManager: Error in #{method_name}", error: e)
109
+ @event_bus&.publish('plugin.error', method: method_name, error: e.message)
110
+ [false, e]
111
+ end
112
+ end
113
+
114
+ # Get plugin health status
115
+ def plugin_health
116
+ {
117
+ total_plugins: @plugins.size,
118
+ plugin_types: @plugins.keys,
119
+ errors: @plugin_errors.transform_values { |e| e.message }
120
+ }
121
+ end
122
+
123
+ # Reload a specific plugin
124
+ def reload_plugin(type)
125
+ @plugins.delete(type)
126
+ load_plugins
127
+ @logger&.info("PluginManager: Reloaded plugin type '#{type}'")
128
+ end
129
+
130
+ # Unregister a plugin
131
+ def unregister(type)
132
+ if @plugins.delete(type)
133
+ @logger&.info("PluginManager: Unregistered plugin type '#{type}'")
134
+ @event_bus&.publish('plugin.unregistered', type: type)
135
+ true
136
+ else
137
+ false
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,93 @@
1
+ # Background polling service for fetching messages
2
+ require 'thread'
3
+
4
+ module Heathrow
5
+ class Poller
6
+ attr_reader :running
7
+
8
+ def initialize(db, plugin_manager)
9
+ @db = db
10
+ @plugin_manager = plugin_manager
11
+ @running = false
12
+ @thread = nil
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def start
17
+ return if @running
18
+
19
+ @running = true
20
+ @thread = Thread.new do
21
+ run_polling_loop
22
+ end
23
+ end
24
+
25
+ def stop
26
+ @running = false
27
+ @thread&.join(5) # Wait up to 5 seconds for thread to finish
28
+ end
29
+
30
+ private
31
+
32
+ def run_polling_loop
33
+ while @running
34
+ begin
35
+ poll_sources
36
+ sleep 10 # Check every 10 seconds
37
+ rescue StandardError => e
38
+ log_error("Polling error: #{e.message}")
39
+ end
40
+ end
41
+ end
42
+
43
+ def poll_sources
44
+ sources = @db.get_sources(true) # Only enabled sources
45
+
46
+ sources.each do |source_data|
47
+ source = Source.new(
48
+ id: source_data['id'],
49
+ type: source_data['type'],
50
+ name: source_data['name'],
51
+ config: source_data['config'],
52
+ enabled: source_data['enabled'] == 1,
53
+ poll_interval: source_data['poll_interval'],
54
+ last_poll: source_data['last_poll']
55
+ )
56
+
57
+ next unless source.should_poll?
58
+
59
+ poll_source(source)
60
+ end
61
+ end
62
+
63
+ def poll_source(source)
64
+ plugin = @plugin_manager.create_source(source.type, source)
65
+ return unless plugin
66
+
67
+ begin
68
+ messages = plugin.fetch_messages
69
+
70
+ @mutex.synchronize do
71
+ messages.each do |msg_data|
72
+ message = Message.new(msg_data)
73
+ message.source_id = source.id
74
+ message.source_type = source.type
75
+
76
+ @db.insert_message(message.to_h.values)
77
+ end
78
+
79
+ @db.update_source_poll_time(source.id)
80
+ end
81
+ rescue StandardError => e
82
+ log_error("Error polling #{source.name}: #{e.message}")
83
+ end
84
+ end
85
+
86
+ def log_error(message)
87
+ log_file = File.join(HEATHROW_LOGS, 'poller.log')
88
+ File.open(log_file, 'a') do |f|
89
+ f.puts "[#{Time.now}] #{message}"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'tempfile'
5
+ require 'shellwords'
6
+
7
+ module Heathrow
8
+ # SMTP sender module - supports OAuth2 and standard SMTP
9
+ # Configure oauth2_domains, smtp_command, safe_dir in ~/.heathrowrc
10
+ class SmtpSender
11
+ attr_reader :from_address, :config
12
+
13
+ def initialize(from_address, config = {})
14
+ @from_address = from_address
15
+ @config = config
16
+ cfg = Heathrow::Config.instance
17
+ @oauth2_domains = cfg&.rc('oauth2_domains', %w[gmail.com]) || %w[gmail.com]
18
+ @smtp_command = cfg&.rc('smtp_command', File.expand_path('~/bin/gmail_smtp'))
19
+ @safe_dir = cfg&.rc('safe_dir', File.join(Dir.home, '.heathrow', 'mail'))
20
+ @smtp_log_path = File.join(@safe_dir, '.smtp.log')
21
+ end
22
+
23
+ # Send an email message
24
+ # @param mail [Mail::Message] The mail object to send
25
+ # @param recipients [String, Array] Recipient email addresses
26
+ # @return [Hash] Result with :success and :message keys
27
+ def send(mail, recipients = nil)
28
+ # Extract recipients from mail object if not provided
29
+ recipients ||= extract_recipients(mail)
30
+
31
+ # Determine sending method based on from address
32
+ if uses_oauth2?
33
+ send_via_gmail_smtp(mail, recipients)
34
+ elsif config['smtp_server']
35
+ send_via_smtp_server(mail, recipients)
36
+ else
37
+ { success: false, message: "No SMTP configuration available for #{from_address}" }
38
+ end
39
+ end
40
+
41
+ # Check if this address uses OAuth2 via gmail_smtp
42
+ def uses_oauth2?
43
+ return false unless from_address
44
+
45
+ domain = from_address.split('@').last
46
+ @oauth2_domains.include?(domain)
47
+ end
48
+
49
+ # Class method for quick sending
50
+ def self.send_message(from, to, subject, body, in_reply_to = nil, config = {})
51
+ require 'mail'
52
+
53
+ mail = Mail.new do
54
+ from from
55
+ to to
56
+ subject subject
57
+ body body
58
+ end
59
+
60
+ # Add threading headers if replying
61
+ if in_reply_to
62
+ mail['In-Reply-To'] = in_reply_to
63
+ mail['References'] = in_reply_to
64
+ end
65
+
66
+ sender = new(from, config)
67
+ sender.send(mail)
68
+ end
69
+
70
+ private
71
+
72
+ # Send using integrated OAuth2 module
73
+ def send_via_gmail_smtp(mail, recipients)
74
+ require_relative 'oauth2_smtp'
75
+
76
+ # Use the integrated OAuth2 SMTP module
77
+ oauth2 = Heathrow::OAuth2Smtp.new(from_address)
78
+ result = oauth2.send(mail, recipients)
79
+
80
+ # Add fallback to external script if integrated module fails
81
+ if !result[:success] && File.exist?(@smtp_command)
82
+ send_via_external_script(mail, recipients)
83
+ else
84
+ result
85
+ end
86
+ end
87
+
88
+ # Fallback to external gmail_smtp script
89
+ def send_via_external_script(mail, recipients)
90
+ begin
91
+ tempfile = Tempfile.new(['heathrow-mail', '.eml'])
92
+ tempfile.write(mail.to_s)
93
+ tempfile.flush
94
+
95
+ recipient_list = Array(recipients).map(&:strip).join(' ')
96
+ cmd = "#{@smtp_command} -f #{Shellwords.escape(from_address)} -i #{recipient_list}"
97
+
98
+ success = system("#{cmd} < #{Shellwords.escape(tempfile.path)}")
99
+
100
+ if success
101
+ { success: true, message: "Message sent via OAuth2 (external script)" }
102
+ else
103
+ error_msg = read_smtp_log_error
104
+ { success: false, message: "Send failed: #{error_msg}" }
105
+ end
106
+
107
+ rescue => e
108
+ { success: false, message: "Error sending: #{e.message}" }
109
+ ensure
110
+ tempfile&.close
111
+ tempfile&.unlink
112
+ end
113
+ end
114
+
115
+ # Send using configured SMTP server
116
+ def send_via_smtp_server(mail, recipients)
117
+ require 'net/smtp'
118
+
119
+ begin
120
+ smtp_config = config['smtp_server'] ? config : default_smtp_config
121
+
122
+ smtp = Net::SMTP.new(
123
+ smtp_config['smtp_server'],
124
+ smtp_config['smtp_port'] || 587
125
+ )
126
+
127
+ # Enable TLS for secure ports
128
+ if smtp_config['smtp_port'] != 25
129
+ smtp.enable_starttls
130
+ end
131
+
132
+ # Authenticate and send
133
+ bare_from = from_address[/<([^>]+)>/, 1] || from_address.strip
134
+ bare_recipients = Array(recipients).map { |r| r[/<([^>]+)>/, 1] || r.strip }
135
+
136
+ smtp.start(
137
+ smtp_config['smtp_server'],
138
+ smtp_config['smtp_username'] || bare_from,
139
+ smtp_config['smtp_password'],
140
+ :plain
141
+ ) do |smtp_conn|
142
+ smtp_conn.send_message(
143
+ mail.to_s,
144
+ bare_from,
145
+ bare_recipients
146
+ )
147
+ end
148
+
149
+ { success: true, message: "Message sent via SMTP server" }
150
+
151
+ rescue => e
152
+ { success: false, message: "SMTP send failed: #{e.message}" }
153
+ end
154
+ end
155
+
156
+ # Extract recipients from mail object
157
+ def extract_recipients(mail)
158
+ recipients = []
159
+ recipients += Array(mail.to) if mail.to
160
+ recipients += Array(mail.cc) if mail.cc
161
+ recipients += Array(mail.bcc) if mail.bcc
162
+ recipients.map(&:to_s).uniq
163
+ end
164
+
165
+ # Read last error from SMTP log
166
+ def read_smtp_log_error
167
+ return "Unknown error" unless File.exist?(@smtp_log_path)
168
+
169
+ # Get last 5 lines from log
170
+ lines = File.readlines(@smtp_log_path).last(5)
171
+
172
+ # Look for error indicators
173
+ error_lines = lines.select { |l| l =~ /error|fail|denied|invalid/i }
174
+
175
+ if error_lines.any?
176
+ error_lines.join(' ').strip
177
+ else
178
+ lines.join(' ').strip
179
+ end
180
+ rescue
181
+ "Could not read SMTP log"
182
+ end
183
+
184
+ # Default SMTP configuration for common providers
185
+ def default_smtp_config
186
+ case from_address
187
+ when /@gmail\.com$/
188
+ {
189
+ 'smtp_server' => 'smtp.gmail.com',
190
+ 'smtp_port' => 587,
191
+ 'smtp_username' => from_address
192
+ }
193
+ when /@(outlook|hotmail|live)\.com$/
194
+ {
195
+ 'smtp_server' => 'smtp-mail.outlook.com',
196
+ 'smtp_port' => 587,
197
+ 'smtp_username' => from_address
198
+ }
199
+ else
200
+ {}
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,39 @@
1
+ # Source model for communication sources
2
+ module Heathrow
3
+ class Source
4
+ attr_accessor :id, :type, :name, :config, :enabled, :poll_interval, :last_poll
5
+
6
+ def initialize(attrs = {})
7
+ @id = attrs[:id] || generate_id
8
+ @type = attrs[:type]
9
+ @name = attrs[:name]
10
+ @config = attrs[:config] || {}
11
+ @enabled = attrs[:enabled] != false
12
+ @poll_interval = attrs[:poll_interval] || 60
13
+ @last_poll = attrs[:last_poll]
14
+ end
15
+
16
+ def generate_id
17
+ "#{@type}_#{Time.now.to_i}_#{rand(1000)}"
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ id: @id,
23
+ type: @type,
24
+ name: @name,
25
+ config: @config.to_json,
26
+ enabled: @enabled ? 1 : 0,
27
+ poll_interval: @poll_interval,
28
+ last_poll: @last_poll
29
+ }
30
+ end
31
+
32
+ def should_poll?
33
+ return false unless @enabled
34
+ return true if @last_poll.nil?
35
+
36
+ Time.now - Time.parse(@last_poll) >= @poll_interval
37
+ end
38
+ end
39
+ end