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.
- checksums.yaml +7 -0
- data/.gitignore +58 -0
- data/README.md +205 -0
- data/bin/heathrow +42 -0
- data/bin/heathrowd +283 -0
- data/docs/ARCHITECTURE.md +1172 -0
- data/docs/DATABASE_SCHEMA.md +685 -0
- data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
- data/docs/DISCORD_SETUP.md +142 -0
- data/docs/GMAIL_OAUTH_SETUP.md +120 -0
- data/docs/PLUGIN_SYSTEM.md +1370 -0
- data/docs/PROJECT_PLAN.md +1022 -0
- data/docs/README.md +417 -0
- data/docs/REDDIT_SETUP.md +174 -0
- data/docs/REPLY_FORWARD.md +182 -0
- data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
- data/heathrow.gemspec +34 -0
- data/heathrowd.service +21 -0
- data/img/heathrow.svg +95 -0
- data/img/rss_threaded.png +0 -0
- data/img/sources.png +0 -0
- data/lib/heathrow/address_book.rb +42 -0
- data/lib/heathrow/config.rb +332 -0
- data/lib/heathrow/database.rb +731 -0
- data/lib/heathrow/database_new.rb +392 -0
- data/lib/heathrow/event_bus.rb +175 -0
- data/lib/heathrow/logger.rb +122 -0
- data/lib/heathrow/message.rb +176 -0
- data/lib/heathrow/message_composer.rb +399 -0
- data/lib/heathrow/message_organizer.rb +774 -0
- data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
- data/lib/heathrow/notmuch.rb +45 -0
- data/lib/heathrow/oauth2_smtp.rb +254 -0
- data/lib/heathrow/plugin/base.rb +212 -0
- data/lib/heathrow/plugin_manager.rb +141 -0
- data/lib/heathrow/poller.rb +93 -0
- data/lib/heathrow/smtp_sender.rb +204 -0
- data/lib/heathrow/source.rb +39 -0
- data/lib/heathrow/sources/base.rb +74 -0
- data/lib/heathrow/sources/discord.rb +357 -0
- data/lib/heathrow/sources/gmail.rb +294 -0
- data/lib/heathrow/sources/imap.rb +198 -0
- data/lib/heathrow/sources/instagram.rb +307 -0
- data/lib/heathrow/sources/instagram_fetch.py +101 -0
- data/lib/heathrow/sources/instagram_send.py +55 -0
- data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
- data/lib/heathrow/sources/maildir.rb +606 -0
- data/lib/heathrow/sources/messenger.rb +212 -0
- data/lib/heathrow/sources/messenger_fetch.js +297 -0
- data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
- data/lib/heathrow/sources/messenger_send.js +32 -0
- data/lib/heathrow/sources/messenger_send.py +100 -0
- data/lib/heathrow/sources/reddit.rb +461 -0
- data/lib/heathrow/sources/rss.rb +299 -0
- data/lib/heathrow/sources/slack.rb +375 -0
- data/lib/heathrow/sources/source_manager.rb +328 -0
- data/lib/heathrow/sources/telegram.rb +498 -0
- data/lib/heathrow/sources/webpage.rb +207 -0
- data/lib/heathrow/sources/weechat.rb +479 -0
- data/lib/heathrow/sources/whatsapp.rb +474 -0
- data/lib/heathrow/ui/application.rb +8098 -0
- data/lib/heathrow/ui/navigation.rb +8 -0
- data/lib/heathrow/ui/panes.rb +8 -0
- data/lib/heathrow/ui/source_wizard.rb +567 -0
- data/lib/heathrow/ui/threaded_view.rb +780 -0
- data/lib/heathrow/ui/views.rb +8 -0
- data/lib/heathrow/version.rb +3 -0
- data/lib/heathrow/wizards/discord_wizard.rb +193 -0
- data/lib/heathrow/wizards/slack_wizard.rb +140 -0
- data/lib/heathrow.rb +55 -0
- 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
|