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,294 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/imap'
5
+ require 'gmail_xoauth'
6
+ require 'json'
7
+ require 'fileutils'
8
+ require 'open3'
9
+
10
+ module Heathrow
11
+ module Sources
12
+ class Gmail
13
+ def initialize(source)
14
+ @source = source
15
+ end
16
+
17
+ def name
18
+ 'Gmail'
19
+ end
20
+
21
+ def description
22
+ 'Fetch emails from Gmail using OAuth2'
23
+ end
24
+
25
+ def config_fields
26
+ [
27
+ { name: 'email', label: 'Gmail Address', type: 'text', required: true },
28
+ { name: 'safedir', label: 'Safe Directory', type: 'text', required: true,
29
+ hint: 'Directory containing your .json and .txt OAuth files' },
30
+ { name: 'oauth2_script', label: 'OAuth2 Script Path', type: 'text', required: true,
31
+ hint: 'Path to oauth2.py script' },
32
+ { name: 'folder', label: 'Folder to Monitor', type: 'text', default: 'INBOX' },
33
+ { name: 'fetch_limit', label: 'Max messages per fetch', type: 'number', default: 50 },
34
+ { name: 'check_interval', label: 'Check Interval (seconds)', type: 'number', default: 300 },
35
+ { name: 'mark_as_read', label: 'Mark fetched as read', type: 'boolean', default: false,
36
+ hint: 'CAUTION: Only enable if you want Heathrow to mark emails as read' }
37
+ ]
38
+ end
39
+
40
+ def validate_config(config)
41
+ return { valid: false, error: 'Email is required' } unless config['email']
42
+ return { valid: false, error: 'Safe directory is required' } unless config['safedir']
43
+
44
+ # Check if OAuth files exist
45
+ email = config['email']
46
+ safedir = File.expand_path(config['safedir'])
47
+ json_file = File.join(safedir, "#{email}.json")
48
+ txt_file = File.join(safedir, "#{email}.txt")
49
+
50
+ unless File.exist?(json_file)
51
+ return { valid: false, error: "OAuth JSON file not found: #{json_file}" }
52
+ end
53
+
54
+ unless File.exist?(txt_file)
55
+ return { valid: false, error: "Refresh token file not found: #{txt_file}" }
56
+ end
57
+
58
+ { valid: true }
59
+ end
60
+
61
+ def fetch_messages
62
+ email = @source.config['email']
63
+ safedir = File.expand_path(@source.config['safedir'])
64
+ oauth2_script = File.expand_path(@source.config['oauth2_script'] || '~/bin/oauth2.py')
65
+ folder = @source.config['folder'] || 'INBOX'
66
+ fetch_limit = @source.config['fetch_limit'] || 50
67
+ mark_as_read = @source.config['mark_as_read'] || false
68
+
69
+ # Read OAuth credentials
70
+ json_file = File.join(safedir, "#{email}.json")
71
+ txt_file = File.join(safedir, "#{email}.txt")
72
+
73
+ begin
74
+ jparse = JSON.parse(File.read(json_file))
75
+ clientid = jparse["web"]["client_id"]
76
+ clsecret = jparse["web"]["client_secret"]
77
+ refresh_token = File.read(txt_file).chomp
78
+ rescue StandardError => e
79
+ return [{
80
+ source_id: @source.id,
81
+ source_type: 'gmail',
82
+ sender: 'Gmail',
83
+ subject: 'Configuration Error',
84
+ content: "Failed to read OAuth credentials: #{e.message}",
85
+ timestamp: Time.now.to_s,
86
+ is_read: 0
87
+ }]
88
+ end
89
+
90
+ # Get access token using oauth2.py
91
+ begin
92
+ cmd = "python3 #{oauth2_script} --generate_oauth2_token " \
93
+ "--client_id=#{clientid} --client_secret=#{clsecret} " \
94
+ "--refresh_token=#{refresh_token}"
95
+
96
+ require 'timeout'
97
+ stdout = stderr = nil
98
+ status = nil
99
+
100
+ Timeout::timeout(10) do
101
+ stdout, stderr, status = Open3.capture3(cmd)
102
+ end
103
+
104
+ unless status && status.success?
105
+ raise "OAuth token generation failed: #{stderr || 'No output'}"
106
+ end
107
+
108
+ # Extract access token from output (format: "Access Token: <token>")
109
+ token_match = stdout.match(/Access Token: (.+?)(\n|$)/)
110
+ unless token_match
111
+ raise "Could not extract access token from output: #{stdout}"
112
+ end
113
+ token = token_match[1].strip
114
+ rescue StandardError => e
115
+ return [{
116
+ source_id: @source.id,
117
+ source_type: 'gmail',
118
+ sender: 'Gmail',
119
+ subject: 'Authentication Error',
120
+ content: "Failed to get access token: #{e.message}",
121
+ timestamp: Time.now.to_s,
122
+ is_read: 0
123
+ }]
124
+ end
125
+
126
+ messages = []
127
+
128
+ begin
129
+ # Connect to Gmail IMAP
130
+ imap = Net::IMAP.new('imap.gmail.com', 993, usessl = true, certs = nil, verify = false)
131
+
132
+ # Authenticate with OAuth2
133
+ imap.authenticate('XOAUTH2', email, token)
134
+ imap.select(folder)
135
+
136
+ # Get state file to track which messages we've already seen
137
+ heathrow_home = File.expand_path('~/.heathrow')
138
+ state_file = File.join(heathrow_home, 'state', "gmail_#{@source.id}.json")
139
+ FileUtils.mkdir_p(File.dirname(state_file))
140
+
141
+ seen_uids = if File.exist?(state_file)
142
+ JSON.parse(File.read(state_file))['seen_uids'] || []
143
+ else
144
+ []
145
+ end
146
+
147
+ # Search for messages (both seen and unseen for testing)
148
+ # In production, you might want to use ["UNSEEN"] only
149
+ search_criteria = mark_as_read ? ["UNSEEN"] : ["ALL"]
150
+
151
+ # Get UIDs of messages
152
+ uids = imap.uid_search(search_criteria)
153
+
154
+ # Limit the number of messages to fetch
155
+ new_uids = uids.reject { |uid| seen_uids.include?(uid) }.last(fetch_limit)
156
+
157
+ new_uids.each do |uid|
158
+ begin
159
+ # Fetch the message envelope and body
160
+ fetch_data = imap.uid_fetch(uid, ['ENVELOPE', 'BODY.PEEK[TEXT]', 'FLAGS'])[0]
161
+
162
+ envelope = fetch_data.attr['ENVELOPE']
163
+ body = fetch_data.attr['BODY[TEXT]'] || ''
164
+ flags = fetch_data.attr['FLAGS'] || []
165
+
166
+ # Extract sender
167
+ from = if envelope.from && envelope.from.first
168
+ addr = envelope.from.first
169
+ if addr.name
170
+ "#{addr.name} <#{addr.mailbox}@#{addr.host}>"
171
+ else
172
+ "#{addr.mailbox}@#{addr.host}"
173
+ end
174
+ else
175
+ 'Unknown Sender'
176
+ end
177
+
178
+ # Clean up subject
179
+ subject = envelope.subject || '(No Subject)'
180
+
181
+ # Parse date
182
+ date = envelope.date ? Time.parse(envelope.date) : Time.now
183
+
184
+ # Truncate body for preview
185
+ body_preview = body.to_s
186
+ .force_encoding('UTF-8')
187
+ .encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
188
+ .gsub(/\r\n/, "\n")
189
+ .strip[0..500]
190
+
191
+ # Check if message is unread
192
+ is_unread = !flags.include?(:Seen)
193
+
194
+ messages << {
195
+ source_id: @source.id,
196
+ source_type: 'gmail',
197
+ sender: from,
198
+ subject: subject,
199
+ content: body_preview,
200
+ timestamp: date.to_s,
201
+ is_read: is_unread ? 0 : 1,
202
+ metadata: {
203
+ uid: uid,
204
+ folder: folder,
205
+ message_id: envelope.message_id,
206
+ flags: flags.map(&:to_s)
207
+ }.to_json
208
+ }
209
+
210
+ # Add to seen UIDs list
211
+ seen_uids << uid
212
+
213
+ # IMPORTANT: Only mark as read if explicitly configured
214
+ if mark_as_read && is_unread
215
+ imap.uid_store(uid, "+FLAGS", [:Seen])
216
+ end
217
+ rescue StandardError => e
218
+ # Log error but continue with other messages
219
+ puts "Error fetching message UID #{uid}: #{e.message}" if ENV['DEBUG']
220
+ end
221
+ end
222
+
223
+ # Save state
224
+ File.write(state_file, JSON.pretty_generate({
225
+ seen_uids: seen_uids,
226
+ last_check: Time.now.to_s
227
+ }))
228
+
229
+ # Disconnect
230
+ imap.logout
231
+ imap.disconnect
232
+
233
+ rescue StandardError => e
234
+ return [{
235
+ source_id: @source.id,
236
+ source_type: 'gmail',
237
+ sender: 'Gmail',
238
+ subject: 'Connection Error',
239
+ content: "Failed to connect to Gmail: #{e.message}",
240
+ timestamp: Time.now.to_s,
241
+ is_read: 0
242
+ }]
243
+ end
244
+
245
+ messages
246
+ end
247
+
248
+ def test
249
+ # Test connection and authentication
250
+ email = @source.config['email']
251
+
252
+ begin
253
+ # Try to get token
254
+ messages = fetch_messages
255
+
256
+ if messages.any? { |m| m[:subject] =~ /Error/ }
257
+ { success: false, message: messages.first[:content] }
258
+ else
259
+ {
260
+ success: true,
261
+ message: "Successfully connected to Gmail for #{email}. Found #{messages.size} messages."
262
+ }
263
+ end
264
+ rescue StandardError => e
265
+ { success: false, message: "Test failed: #{e.message}" }
266
+ end
267
+ end
268
+
269
+ def can_reply?
270
+ true
271
+ end
272
+
273
+ def send_message(to, subject, body, in_reply_to = nil)
274
+ config = @source.config.is_a?(String) ? JSON.parse(@source.config) : @source.config
275
+ email = config['email']
276
+
277
+ # Use the SmtpSender module
278
+ require_relative '../smtp_sender'
279
+
280
+ # SmtpSender handles all the complexity of OAuth2 and gmail_smtp
281
+ result = Heathrow::SmtpSender.send_message(
282
+ email,
283
+ to,
284
+ subject,
285
+ body,
286
+ in_reply_to,
287
+ config
288
+ )
289
+
290
+ result
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/imap'
5
+ require 'json'
6
+ require 'fileutils'
7
+
8
+ module Heathrow
9
+ module Sources
10
+ class Imap
11
+ def initialize(source)
12
+ @source = source
13
+ end
14
+
15
+ def name
16
+ 'IMAP Email'
17
+ end
18
+
19
+ def description
20
+ 'Fetch emails from any IMAP server using username/password'
21
+ end
22
+
23
+ def fetch_messages
24
+ server = @source.config['imap_server']
25
+ port = @source.config['imap_port'] || 993
26
+ username = @source.config['username']
27
+ password = @source.config['password']
28
+ folder = @source.config['folder'] || 'INBOX'
29
+ fetch_limit = @source.config['fetch_limit'] || 50
30
+ mark_as_read = @source.config['mark_as_read'] || false
31
+ use_ssl = @source.config['use_ssl'] != false # Default to true
32
+
33
+ messages = []
34
+
35
+ begin
36
+ # Connect to IMAP server
37
+ if use_ssl
38
+ imap = Net::IMAP.new(server, port, usessl = true, certs = nil, verify = false)
39
+ else
40
+ imap = Net::IMAP.new(server, port)
41
+ end
42
+
43
+ # Login with username/password
44
+ imap.login(username, password)
45
+ imap.select(folder)
46
+
47
+ # Get state file to track which messages we've already seen
48
+ heathrow_home = File.expand_path('~/.heathrow')
49
+ state_file = File.join(heathrow_home, 'state', "imap_#{@source.id}.json")
50
+ FileUtils.mkdir_p(File.dirname(state_file))
51
+
52
+ seen_uids = if File.exist?(state_file)
53
+ JSON.parse(File.read(state_file))['seen_uids'] || []
54
+ else
55
+ []
56
+ end
57
+
58
+ # Search for unseen messages
59
+ unseen_uids = imap.search(["UNSEEN"])
60
+
61
+ # Get UIDs not already processed
62
+ new_uids = unseen_uids - seen_uids
63
+ new_uids = new_uids.first(fetch_limit) if new_uids.length > fetch_limit
64
+
65
+ new_uids.each do |uid|
66
+ msg = imap.uid_fetch(uid, ["ENVELOPE", "BODY[TEXT]", "FLAGS"]).first
67
+ next unless msg
68
+
69
+ envelope = msg.attr["ENVELOPE"]
70
+ body = msg.attr["BODY[TEXT]"]
71
+ flags = msg.attr["FLAGS"]
72
+
73
+ from = envelope.from&.first
74
+ sender = from ? "#{from.name || ''} <#{from.mailbox}@#{from.host}>" : "Unknown"
75
+
76
+ is_unread = !flags.include?(:Seen)
77
+
78
+ # Mark as read if configured to do so
79
+ if mark_as_read && is_unread
80
+ imap.uid_store(uid, "+FLAGS", [:Seen])
81
+ end
82
+
83
+ message = {
84
+ source_id: @source.id,
85
+ source_type: 'imap',
86
+ external_id: "imap_#{username}_#{uid}",
87
+ sender: sender,
88
+ recipient: username,
89
+ subject: envelope.subject || "(no subject)",
90
+ content: body || "",
91
+ raw_data: {
92
+ envelope: envelope,
93
+ flags: flags,
94
+ uid: uid
95
+ }.to_json,
96
+ attachments: nil,
97
+ timestamp: envelope.date || Time.now,
98
+ is_read: flags.include?(:Seen) ? 1 : 0
99
+ }
100
+
101
+ messages << message
102
+ seen_uids << uid
103
+ end
104
+
105
+ # Update state file
106
+ File.write(state_file, JSON.generate({
107
+ seen_uids: seen_uids,
108
+ last_fetch: Time.now.to_s
109
+ }))
110
+
111
+ imap.logout
112
+ imap.disconnect
113
+ rescue Net::IMAP::NoResponseError => e
114
+ # Authentication failed
115
+ return [{
116
+ source_id: @source.id,
117
+ source_type: 'imap',
118
+ sender: 'IMAP',
119
+ subject: 'Authentication Failed',
120
+ content: "Failed to login to #{server}: #{e.message}",
121
+ timestamp: Time.now.to_s,
122
+ is_read: 0
123
+ }]
124
+ rescue => e
125
+ # Other errors
126
+ return [{
127
+ source_id: @source.id,
128
+ source_type: 'imap',
129
+ sender: 'IMAP',
130
+ subject: 'Connection Error',
131
+ content: "Error connecting to #{server}: #{e.message}",
132
+ timestamp: Time.now.to_s,
133
+ is_read: 0
134
+ }]
135
+ end
136
+
137
+ messages
138
+ end
139
+
140
+ def test_connection
141
+ config = @source.config.is_a?(String) ? JSON.parse(@source.config) : @source.config
142
+ server = config['imap_server']
143
+ port = config['imap_port'] || 993
144
+ username = config['username']
145
+ password = config['password']
146
+ use_ssl = config['use_ssl'] != false
147
+
148
+ begin
149
+ if use_ssl
150
+ imap = Net::IMAP.new(server, port, usessl = true, certs = nil, verify = false)
151
+ else
152
+ imap = Net::IMAP.new(server, port)
153
+ end
154
+
155
+ imap.login(username, password)
156
+
157
+ # Get folder list to verify connection
158
+ folders = imap.list('', '*')
159
+ folder_count = folders&.size || 0
160
+
161
+ imap.logout
162
+ imap.disconnect
163
+
164
+ { success: true, message: "Connected to #{server} (#{folder_count} folders)" }
165
+ rescue => e
166
+ { success: false, message: "Connection failed: #{e.message}" }
167
+ end
168
+ end
169
+
170
+ def can_reply?
171
+ config = @source.config.is_a?(String) ? JSON.parse(@source.config) : @source.config
172
+ # Check if we have SMTP configuration or can use gmail_smtp
173
+ config['smtp_server'] || config['username']&.include?('@')
174
+ end
175
+
176
+ def send_message(to, subject, body, in_reply_to = nil)
177
+ config = @source.config.is_a?(String) ? JSON.parse(@source.config) : @source.config
178
+ from = config['username']
179
+
180
+ # Use the SmtpSender module
181
+ require_relative '../smtp_sender'
182
+
183
+ # SmtpSender will automatically detect OAuth2 domains and use gmail_smtp
184
+ # or fall back to SMTP server configuration
185
+ result = Heathrow::SmtpSender.send_message(
186
+ from,
187
+ to,
188
+ subject,
189
+ body,
190
+ in_reply_to,
191
+ config
192
+ )
193
+
194
+ result
195
+ end
196
+ end
197
+ end
198
+ end