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,176 @@
1
+ # Message model - matches DATABASE_SCHEMA.md spec
2
+ require 'json'
3
+
4
+ module Heathrow
5
+ class Message
6
+ attr_accessor :id, :source_id, :external_id, :thread_id, :parent_id,
7
+ :sender, :sender_name,
8
+ :recipients, :cc, :bcc,
9
+ :subject, :content, :html_content,
10
+ :timestamp, :received_at,
11
+ :read, :starred, :archived,
12
+ :labels, :attachments, :metadata
13
+
14
+ # Backward compatibility aliases
15
+ alias :is_read :read
16
+ alias :is_read= :read=
17
+ alias :is_starred :starred
18
+ alias :is_starred= :starred=
19
+
20
+ def initialize(attrs = {})
21
+ # Set defaults
22
+ @timestamp = Time.now.to_i
23
+ @received_at = Time.now.to_i
24
+ @read = false
25
+ @starred = false
26
+ @archived = false
27
+ @recipients = []
28
+ @cc = []
29
+ @bcc = []
30
+ @labels = []
31
+ @attachments = []
32
+ @metadata = {}
33
+
34
+ # Set attributes from hash
35
+ attrs.each do |key, value|
36
+ # Handle both string and symbol keys
37
+ key = key.to_s if key.is_a?(Symbol)
38
+
39
+ # Parse JSON strings for array/hash fields
40
+ if ['recipients', 'cc', 'bcc', 'labels', 'attachments', 'metadata'].include?(key)
41
+ value = parse_json_field(value)
42
+ end
43
+
44
+ # Handle backward compatibility
45
+ key = 'read' if key == 'is_read'
46
+ key = 'starred' if key == 'is_starred'
47
+
48
+ send("#{key}=", value) if respond_to?("#{key}=")
49
+ end
50
+ end
51
+
52
+ def to_h
53
+ {
54
+ id: @id,
55
+ source_id: @source_id,
56
+ external_id: @external_id,
57
+ thread_id: @thread_id,
58
+ parent_id: @parent_id,
59
+ sender: @sender,
60
+ sender_name: @sender_name,
61
+ recipients: @recipients,
62
+ cc: @cc,
63
+ bcc: @bcc,
64
+ subject: @subject,
65
+ content: @content,
66
+ html_content: @html_content,
67
+ timestamp: @timestamp,
68
+ received_at: @received_at,
69
+ read: @read,
70
+ starred: @starred,
71
+ archived: @archived,
72
+ labels: @labels,
73
+ attachments: @attachments,
74
+ metadata: @metadata
75
+ }
76
+ end
77
+
78
+ # Message state methods
79
+ def mark_as_read!
80
+ @read = true
81
+ end
82
+
83
+ def mark_as_unread!
84
+ @read = false
85
+ end
86
+
87
+ def toggle_star!
88
+ @starred = !@starred
89
+ end
90
+
91
+ def archive!
92
+ @archived = true
93
+ end
94
+
95
+ def unarchive!
96
+ @archived = false
97
+ end
98
+
99
+ def add_label(label)
100
+ @labels << label unless @labels.include?(label)
101
+ end
102
+
103
+ def remove_label(label)
104
+ @labels.delete(label)
105
+ end
106
+
107
+ def has_label?(label)
108
+ @labels.include?(label)
109
+ end
110
+
111
+ # Query methods
112
+ def read?
113
+ @read
114
+ end
115
+
116
+ def unread?
117
+ !@read
118
+ end
119
+
120
+ def starred?
121
+ @starred
122
+ end
123
+
124
+ def archived?
125
+ @archived
126
+ end
127
+
128
+ def has_attachments?
129
+ @attachments && !@attachments.empty?
130
+ end
131
+
132
+ def has_thread?
133
+ !@thread_id.nil?
134
+ end
135
+
136
+ def is_reply?
137
+ !@parent_id.nil?
138
+ end
139
+
140
+ # Display helpers
141
+ def short_subject(length = 50)
142
+ return '' unless @subject
143
+ @subject.length > length ? "#{@subject[0...length]}..." : @subject
144
+ end
145
+
146
+ def short_content(length = 100)
147
+ return '' unless @content
148
+ @content.length > length ? "#{@content[0...length]}..." : @content
149
+ end
150
+
151
+ def display_sender
152
+ @sender_name || @sender || 'Unknown'
153
+ end
154
+
155
+ def timestamp_formatted(format = '%Y-%m-%d %H:%M:%S')
156
+ Time.at(@timestamp).strftime(format)
157
+ end
158
+
159
+ def age_in_days
160
+ ((Time.now.to_i - @timestamp) / 86400.0).round(1)
161
+ end
162
+
163
+ private
164
+
165
+ def parse_json_field(value)
166
+ return value if value.nil?
167
+ return value unless value.is_a?(String)
168
+
169
+ begin
170
+ JSON.parse(value)
171
+ rescue JSON::ParserError
172
+ value
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'tempfile'
5
+ require 'shellwords'
6
+
7
+ module Heathrow
8
+ class MessageComposer
9
+ attr_reader :editor, :message
10
+
11
+ def initialize(message = nil, identity: nil, address_book: nil, editor_args: nil)
12
+ @message = message
13
+ @editor = ENV['EDITOR'] || 'vim'
14
+ @identity = identity
15
+ @address_book = address_book
16
+ @editor_args = editor_args
17
+ end
18
+
19
+ # Compose a reply to a message
20
+ def compose_reply(include_all_recipients = false)
21
+ template = build_reply_template(include_all_recipients)
22
+ content = edit_in_editor(template)
23
+ return nil if content.nil? || content.strip.empty?
24
+ parse_composed_message(content)
25
+ end
26
+
27
+ # Compose a forward of a message
28
+ def compose_forward
29
+ template = build_forward_template
30
+ content = edit_in_editor(template)
31
+ return nil if content.nil? || content.strip.empty?
32
+ parse_composed_message(content)
33
+ end
34
+
35
+ # Compose a new message
36
+ def compose_new(to = nil, subject = nil)
37
+ template = build_new_template(to, subject)
38
+ # Cursor on "To: " line (line 2)
39
+ content = edit_in_editor(template, cursor_line: 2)
40
+ return nil if content.nil? || content.strip.empty?
41
+ parse_composed_message(content)
42
+ end
43
+
44
+ # Resume a postponed draft
45
+ def compose_draft(draft)
46
+ template = header_block(
47
+ to: draft['to'] || '',
48
+ cc: draft['cc'] || '',
49
+ bcc: draft['bcc'] || '',
50
+ subject: draft['subject'] || '',
51
+ reply_to: draft['reply_to']
52
+ )
53
+ template << ""
54
+ template << (draft['body'] || '')
55
+
56
+ content = edit_in_editor(template.join("\n"))
57
+ return nil if content.nil? || content.strip.empty?
58
+ parse_composed_message(content)
59
+ end
60
+
61
+ private
62
+
63
+ # --- Header block shared by all templates ---
64
+
65
+ def header_block(to: '', cc: '', bcc: '', subject: '', reply_to: nil)
66
+ from = @identity ? @identity[:from] : ''
67
+ reply_to ||= @identity ? (@identity[:from][/<([^>]+)>/, 1] || @identity[:from]) : ''
68
+
69
+ lines = []
70
+ lines << "From: #{from}"
71
+ lines << "To: #{to}"
72
+ lines << "Cc: #{cc}"
73
+ lines << "Bcc: "
74
+ lines << "Reply-To: #{reply_to}"
75
+ lines << "Subject: #{subject}"
76
+
77
+ # Custom headers from identity (includes global headers merged by Config)
78
+ if @identity && @identity[:headers]
79
+ @identity[:headers].each { |k, v| lines << "#{k}: #{v}" }
80
+ end
81
+
82
+ lines
83
+ end
84
+
85
+ # --- Templates ---
86
+
87
+ def build_reply_template(include_all)
88
+ return build_new_template unless @message
89
+
90
+ original_from = @message['sender'] || 'Unknown'
91
+ original_to = @message['recipient'] || @message['recipients'] || ''
92
+ original_subject = @message['subject'] || ''
93
+ original_content = @message['content'] || ''
94
+ original_date = @message['timestamp'] || Time.now.to_s
95
+ source_type = @message['source_type']
96
+
97
+ original_cc = @message['cc']
98
+ original_cc = JSON.parse(original_cc) if original_cc.is_a?(String) rescue []
99
+ original_cc = Array(original_cc)
100
+ my_addr = @identity ? (@identity[:from][/<([^>]+)>/, 1] || @identity[:from]) : nil
101
+
102
+ # To: always the original sender
103
+ to = if %w[discord slack telegram].include?(source_type)
104
+ original_to
105
+ else
106
+ original_from
107
+ end
108
+
109
+ # Cc: for reply-all, gather original To + Cc minus ourselves
110
+ cc = ''
111
+ if include_all && !%w[discord slack telegram].include?(source_type)
112
+ all = []
113
+ if original_to.is_a?(Array)
114
+ all += original_to
115
+ elsif original_to.is_a?(String) && !original_to.empty?
116
+ all += original_to.split(',').map(&:strip)
117
+ end
118
+ all += original_cc
119
+ all.reject! { |a| a == 'Me' || (my_addr && a.downcase.include?(my_addr.downcase)) }
120
+ cc = all.uniq.join(', ')
121
+ end
122
+
123
+ subject = original_subject
124
+ subject = "Re: #{subject}" unless subject.start_with?('Re:')
125
+
126
+ template = header_block(to: to, cc: cc, subject: subject)
127
+ template << ""
128
+ template << ""
129
+
130
+ # Attribution line + quoted text
131
+ template << format_attribution(original_date, original_from)
132
+ original_content.each_line { |line| template << "> #{line.chomp}" }
133
+
134
+ # Signature at the bottom (after quoted text, like mutt)
135
+ sig = get_signature
136
+ if sig
137
+ template << ""
138
+ template << ""
139
+ template << "-- "
140
+ template << sig
141
+ end
142
+
143
+ template.map { |s| s.encode('UTF-8', invalid: :replace, undef: :replace) }.join("\n")
144
+ end
145
+
146
+ def build_forward_template
147
+ return build_new_template unless @message
148
+
149
+ original_from = @message['sender'] || 'Unknown'
150
+ original_subject = @message['subject'] || ''
151
+ original_content = @message['content'] || ''
152
+ original_date = @message['timestamp'] || Time.now.to_s
153
+
154
+ subject = original_subject
155
+ subject = "Fwd: #{subject}" unless subject.start_with?('Fwd:')
156
+
157
+ template = header_block(subject: subject)
158
+ template << ""
159
+ template << ""
160
+ template << "---------- Forwarded message ----------"
161
+ template << "From: #{original_from}"
162
+ template << "Date: #{format_date(original_date)}"
163
+ template << "Subject: #{original_subject}"
164
+ template << ""
165
+ template << original_content
166
+
167
+ # Signature at the bottom
168
+ sig = get_signature
169
+ if sig
170
+ template << ""
171
+ template << ""
172
+ template << "-- "
173
+ template << sig
174
+ end
175
+
176
+ template.map { |s| s.encode('UTF-8', invalid: :replace, undef: :replace) }.join("\n")
177
+ end
178
+
179
+ def build_new_template(to = nil, subject = nil)
180
+ template = header_block(to: to || '', subject: subject || '')
181
+ template << ""
182
+ template << ""
183
+
184
+ sig = get_signature
185
+ if sig
186
+ template << ""
187
+ template << "-- "
188
+ template << sig
189
+ end
190
+
191
+ template.join("\n")
192
+ end
193
+
194
+ # --- Attribution ---
195
+
196
+ def format_date(timestamp)
197
+ t = case timestamp
198
+ when Integer then Time.at(timestamp)
199
+ when String
200
+ Integer(timestamp) rescue nil ? Time.at(Integer(timestamp)) : (Time.parse(timestamp) rescue Time.now)
201
+ else Time.now
202
+ end
203
+ t.strftime('%a, %b %d, %Y at %H:%M:%S(%P) %z')
204
+ end
205
+
206
+ def format_attribution(timestamp, sender)
207
+ date_str = format_date(timestamp)
208
+ # Use sender_name if available, otherwise extract from "Name <email>" or use as-is
209
+ name = @message && @message['sender_name'] && !@message['sender_name'].to_s.empty? ?
210
+ @message['sender_name'] : (sender[/^([^<]+)/, 1]&.strip || sender)
211
+
212
+ # Configurable via RC: set :attribution, 'On %d, %n wrote:'
213
+ # %d = date, %n = name, %e = email
214
+ pattern = Heathrow::Config.instance&.rc('attribution') rescue nil
215
+ if pattern
216
+ email = sender[/<([^>]+)>/, 1] || sender
217
+ line = pattern.gsub('%d', date_str).gsub('%n', name).gsub('%e', email)
218
+ else
219
+ line = "On #{date_str}, #{name} wrote:"
220
+ end
221
+ line
222
+ end
223
+
224
+ # --- Signature ---
225
+
226
+ def get_signature
227
+ return nil unless @identity && @identity[:signature]
228
+ sig_path = @identity[:signature]
229
+ return nil unless File.exist?(sig_path)
230
+
231
+ if File.executable?(sig_path)
232
+ `#{Shellwords.escape(sig_path)}`.chomp
233
+ else
234
+ File.read(sig_path).chomp
235
+ end
236
+ rescue
237
+ nil
238
+ end
239
+
240
+ # --- Address expansion ---
241
+
242
+ # Expand aliases in a comma-separated address field.
243
+ # "b, bent" → "Brendan Martin <brendan@example.com>, Bent Brakas <bent@example.com>"
244
+ def expand_addresses(field)
245
+ return field if field.nil? || field.empty?
246
+ field.split(',').map { |addr|
247
+ addr = addr.strip
248
+ expanded = @address_book.expand(addr)
249
+ # If unchanged and no angle brackets, try case-insensitive lookup
250
+ if expanded == addr && !addr.include?('@') && !addr.include?('<')
251
+ matches = @address_book.lookup(addr)
252
+ expanded = matches.values.first if matches.size == 1
253
+ end
254
+ expanded
255
+ }.join(', ')
256
+ end
257
+
258
+ # --- Editor ---
259
+
260
+ def edit_in_editor(template, cursor_line: nil)
261
+ tempfile = Tempfile.new(['heathrow-compose', '.eml'])
262
+
263
+ begin
264
+ tempfile.write(template)
265
+ tempfile.flush
266
+
267
+ # Find cursor line: second blank line after headers (body start) if not specified
268
+ unless cursor_line
269
+ lines = template.lines
270
+ found_separator = false
271
+ lines.each_with_index do |line, i|
272
+ if !found_separator && line.strip.empty?
273
+ found_separator = true
274
+ next
275
+ end
276
+ if found_separator
277
+ cursor_line = i + 1 # vim is 1-indexed, line after separator
278
+ break
279
+ end
280
+ end
281
+ cursor_line ||= 1
282
+ end
283
+
284
+ # Restore terminal for editor
285
+ system("stty sane 2>/dev/null")
286
+ print "\e[?25h"
287
+ # Position cursor with user-configurable editor args
288
+ args = @editor_args.to_s.strip
289
+ if @editor =~ /vim?\b/
290
+ args = "-c 'startinsert!'" if args.empty?
291
+ system("#{@editor} +#{cursor_line} #{args} #{Shellwords.escape(tempfile.path)}")
292
+ else
293
+ system("#{@editor} #{args} #{Shellwords.escape(tempfile.path)}")
294
+ end
295
+ success = $?.success?
296
+ # Restore raw mode for rcurses
297
+ $stdin.raw!
298
+ $stdin.echo = false
299
+ print "\e[?25l"
300
+ Rcurses.clear_screen if defined?(Rcurses)
301
+
302
+ if success
303
+ tempfile.rewind
304
+ content = tempfile.read
305
+
306
+ # Treat unchanged content as cancel
307
+ return nil if content.rstrip == template.rstrip
308
+
309
+ content
310
+ else
311
+ nil
312
+ end
313
+ ensure
314
+ tempfile.close
315
+ tempfile.unlink
316
+ end
317
+ end
318
+
319
+ # --- Parser ---
320
+
321
+ def parse_composed_message(content)
322
+ lines = content.lines
323
+
324
+ from = nil
325
+ to = nil
326
+ cc = nil
327
+ bcc = nil
328
+ reply_to = nil
329
+ subject = nil
330
+ extra_headers = {}
331
+ body_lines = []
332
+ in_body = false
333
+
334
+ lines.each do |line|
335
+ line = line.chomp
336
+ next if line.start_with?('#')
337
+
338
+ if !in_body
339
+ case line
340
+ when /^From:\s*(.*)/ then from = $1.strip
341
+ when /^To:\s*(.*)/ then to = $1.strip
342
+ when /^Cc:\s*(.*)/ then cc = $1.strip
343
+ when /^Bcc:\s*(.*)/ then bcc = $1.strip
344
+ when /^Reply-To:\s*(.*)/ then reply_to = $1.strip
345
+ when /^Subject:\s*(.*)/ then subject = $1.strip
346
+ when /^(X-[^:]+):\s*(.*)/ then extra_headers[$1] = $2.strip
347
+ when /^\s*$/ then in_body = true
348
+ else
349
+ in_body = true
350
+ body_lines << line
351
+ end
352
+ else
353
+ body_lines << line
354
+ end
355
+ end
356
+
357
+ body = body_lines.join("\n").strip
358
+
359
+ # Expand address book aliases in To/Cc/Bcc
360
+ if @address_book
361
+ to = expand_addresses(to) if to
362
+ cc = expand_addresses(cc) if cc
363
+ bcc = expand_addresses(bcc) if bcc
364
+ end
365
+
366
+ return nil if to.nil? || to.empty?
367
+ return nil if body.empty?
368
+
369
+ # If user wrote nothing new (only quoted text + signature), treat as cancel
370
+ in_sig = false
371
+ new_content = []
372
+ body.each_line do |l|
373
+ if l.rstrip == '-- ' || l.rstrip == '--'
374
+ in_sig = true
375
+ next
376
+ end
377
+ next if in_sig
378
+ next if l.start_with?('>')
379
+ next if l =~ /^On .+ wrote:$/
380
+ next if l =~ /^-+ Forwarded message -+$/
381
+ next if l =~ /^(From|Date|Subject): /
382
+ new_content << l
383
+ end
384
+ return nil if new_content.all? { |l| l.strip.empty? }
385
+
386
+ {
387
+ from: from,
388
+ to: to,
389
+ cc: (cc && !cc.empty?) ? cc : nil,
390
+ bcc: (bcc && !bcc.empty?) ? bcc : nil,
391
+ reply_to: (reply_to && !reply_to.empty?) ? reply_to : nil,
392
+ subject: (subject.nil? || subject.empty?) ? '(no subject)' : subject,
393
+ extra_headers: extra_headers.empty? ? nil : extra_headers,
394
+ body: body,
395
+ original_message: @message
396
+ }
397
+ end
398
+ end
399
+ end