heathrow 0.7.0 → 0.7.2
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 +4 -4
- data/.gitignore +12 -3
- data/lib/heathrow/message_composer.rb +2 -1
- data/lib/heathrow/sources/maildir.rb +10 -2
- data/lib/heathrow/ui/application.rb +93 -26
- data/lib/heathrow/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cbe8be982ab3300b591a24bdba7bb67c9feff58167a7785fb288a4b62d558f46
|
|
4
|
+
data.tar.gz: 513f905dff1e8a9e74b87488a1fa5b007d3a76e26287d7643961ea2b1b0b2268
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29e42a3922e7dfb70188c3bc03345812ccb1a7a21c992384e1b50938a00418ecb6bdba009ba30d0020960b354f144b0ac1a9cc18910b2c2e775c0137a7f68c61
|
|
7
|
+
data.tar.gz: 3a7a0e541c490c8d783f679d6be4bcab6dabe703041ea2620d95b7756ec74cc53d9bfee851e15f3863685168f8c33f1c1b227ecf5e1aea784681d4b12cfb6f16
|
data/.gitignore
CHANGED
|
@@ -18,17 +18,26 @@
|
|
|
18
18
|
/lib/bundler/man/
|
|
19
19
|
.bundle/
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
~/.
|
|
21
|
+
# Heathrow data
|
|
22
|
+
~/.heathrow/
|
|
23
23
|
*.db
|
|
24
24
|
*.db-shm
|
|
25
25
|
*.db-wal
|
|
26
26
|
/logs/
|
|
27
27
|
/attachments/
|
|
28
28
|
|
|
29
|
-
#
|
|
29
|
+
# Secrets and credentials
|
|
30
30
|
.env
|
|
31
31
|
.env.local
|
|
32
|
+
.env.*
|
|
33
|
+
*.pem
|
|
34
|
+
*.key
|
|
35
|
+
*.p12
|
|
36
|
+
*.pfx
|
|
37
|
+
credentials.json
|
|
38
|
+
token.json
|
|
39
|
+
*_secret*
|
|
40
|
+
*_token*
|
|
32
41
|
|
|
33
42
|
# Editor directories
|
|
34
43
|
.vscode/
|
|
@@ -27,7 +27,8 @@ module Heathrow
|
|
|
27
27
|
# Compose a forward of a message
|
|
28
28
|
def compose_forward
|
|
29
29
|
template = build_forward_template
|
|
30
|
-
|
|
30
|
+
# Cursor on "To: " line (line 2)
|
|
31
|
+
content = edit_in_editor(template, cursor_line: 2)
|
|
31
32
|
return nil if content.nil? || content.strip.empty?
|
|
32
33
|
parse_composed_message(content)
|
|
33
34
|
end
|
|
@@ -170,9 +170,17 @@ module Heathrow
|
|
|
170
170
|
changed_base_ids.each do |base_id|
|
|
171
171
|
flags = self.class.parse_maildir_flags(disk_files[base_id])
|
|
172
172
|
db_row = db_index[base_id]
|
|
173
|
-
|
|
173
|
+
# For replied: DB is authoritative. If DB says replied but disk
|
|
174
|
+
# doesn't have R flag, add it to disk rather than clearing DB.
|
|
175
|
+
disk_replied = flags[:replied]
|
|
176
|
+
db_replied = db_row[:replied] == 1
|
|
177
|
+
if db_replied && !disk_replied
|
|
178
|
+
rename_with_flag(disk_files[base_id], 'R', add: true)
|
|
179
|
+
disk_replied = true
|
|
180
|
+
end
|
|
181
|
+
if flags[:seen] != (db_row[:read] == 1) || flags[:flagged] != (db_row[:starred] == 1) || disk_replied != db_replied
|
|
174
182
|
db.execute("UPDATE messages SET read = ?, starred = ?, replied = ? WHERE id = ?",
|
|
175
|
-
flags[:seen] ? 1 : 0, flags[:flagged] ? 1 : 0,
|
|
183
|
+
flags[:seen] ? 1 : 0, flags[:flagged] ? 1 : 0, disk_replied ? 1 : 0, db_row[:id])
|
|
176
184
|
end
|
|
177
185
|
# Update external_id and metadata if filename changed
|
|
178
186
|
current_filename = File.basename(disk_files[base_id])
|
|
@@ -1979,28 +1979,28 @@ module Heathrow
|
|
|
1979
1979
|
end
|
|
1980
1980
|
else
|
|
1981
1981
|
# Regular message display
|
|
1982
|
-
header << "From: #{msg['sender']}".fg(
|
|
1982
|
+
header << "From: #{msg['sender']}".fg(2) if msg['sender']
|
|
1983
1983
|
# Show recipients (To field)
|
|
1984
1984
|
to = msg['recipients'] || msg['recipient']
|
|
1985
1985
|
if to
|
|
1986
1986
|
to_list = to.is_a?(String) ? (JSON.parse(to) rescue [to]) : to
|
|
1987
1987
|
to_str = to_list.is_a?(Array) ? to_list.join(', ') : to_list.to_s
|
|
1988
|
-
header << "To: #{to_str}".fg(
|
|
1988
|
+
header << "To: #{to_str}".fg(2) unless to_str.empty?
|
|
1989
1989
|
end
|
|
1990
1990
|
# Show CC recipients
|
|
1991
1991
|
cc = msg['cc']
|
|
1992
1992
|
if cc
|
|
1993
1993
|
cc_list = cc.is_a?(String) ? (JSON.parse(cc) rescue [cc]) : cc
|
|
1994
1994
|
cc_str = cc_list.is_a?(Array) ? cc_list.join(', ') : cc_list.to_s
|
|
1995
|
-
header << "Cc: #{cc_str}".fg(
|
|
1995
|
+
header << "Cc: #{cc_str}".fg(2) unless cc_str.empty?
|
|
1996
1996
|
end
|
|
1997
1997
|
# For weechat, show channel name from metadata instead of content preview
|
|
1998
1998
|
meta = msg['metadata']
|
|
1999
1999
|
meta = JSON.parse(meta) if meta.is_a?(String) rescue nil
|
|
2000
2000
|
if meta.is_a?(Hash) && meta['channel_name']
|
|
2001
|
-
header << "Subject: #{meta['channel_name']}".b.fg(
|
|
2001
|
+
header << "Subject: #{meta['channel_name']}".b.fg(1)
|
|
2002
2002
|
elsif msg['subject']
|
|
2003
|
-
header << "Subject: #{msg['subject']}".b.fg(
|
|
2003
|
+
header << "Subject: #{msg['subject']}".b.fg(1)
|
|
2004
2004
|
end
|
|
2005
2005
|
end
|
|
2006
2006
|
|
|
@@ -3702,6 +3702,7 @@ module Heathrow
|
|
|
3702
3702
|
def show_folder_browser
|
|
3703
3703
|
@in_folder_browser = true
|
|
3704
3704
|
@folder_browser_index = 0
|
|
3705
|
+
@panes[:top].bg = @topcolor
|
|
3705
3706
|
@folder_collapsed ||= {}
|
|
3706
3707
|
@folder_count_cache = {} # Fresh counts each time
|
|
3707
3708
|
@browser_favorites = nil # Fresh favorites read
|
|
@@ -3969,6 +3970,7 @@ module Heathrow
|
|
|
3969
3970
|
favorites = get_favorite_folders
|
|
3970
3971
|
@in_folder_browser = true
|
|
3971
3972
|
@folder_browser_index = 0
|
|
3973
|
+
@panes[:top].bg = @topcolor
|
|
3972
3974
|
@folder_count_cache = {} # Fresh counts each time
|
|
3973
3975
|
|
|
3974
3976
|
# Build display from favorites only (no DB queries — counts fetched on select)
|
|
@@ -4250,6 +4252,13 @@ module Heathrow
|
|
|
4250
4252
|
msg['folder'] = dest
|
|
4251
4253
|
msg['metadata'] = metadata
|
|
4252
4254
|
msg['labels'] = labels
|
|
4255
|
+
|
|
4256
|
+
# Mark as read (we've seen it if we're filing it)
|
|
4257
|
+
if msg['is_read'].to_i == 0
|
|
4258
|
+
@db.mark_as_read(msg['id'])
|
|
4259
|
+
msg['is_read'] = 1
|
|
4260
|
+
sync_maildir_flag(msg, 'S', true)
|
|
4261
|
+
end
|
|
4253
4262
|
end
|
|
4254
4263
|
|
|
4255
4264
|
# ── Save by browsing folders (sB) ──
|
|
@@ -4517,7 +4526,7 @@ module Heathrow
|
|
|
4517
4526
|
def ai_assistant
|
|
4518
4527
|
msg = current_message
|
|
4519
4528
|
return unless msg && !msg['is_header'] && !msg['is_channel_header'] && !msg['is_thread_header']
|
|
4520
|
-
ensure_full_message(msg)
|
|
4529
|
+
msg = ensure_full_message(msg)
|
|
4521
4530
|
|
|
4522
4531
|
# Build message context
|
|
4523
4532
|
context = ai_message_context(msg)
|
|
@@ -4963,9 +4972,19 @@ module Heathrow
|
|
|
4963
4972
|
end
|
|
4964
4973
|
|
|
4965
4974
|
def ensure_full_message(msg)
|
|
4966
|
-
if msg && msg['id'] && !msg
|
|
4975
|
+
if msg && msg['id'] && !msg['_full_loaded'] && !msg['is_header']
|
|
4967
4976
|
full = @db.get_message(msg['id'])
|
|
4968
|
-
|
|
4977
|
+
if full
|
|
4978
|
+
if msg.frozen?
|
|
4979
|
+
# Replace frozen hash in filtered_messages with mutable copy
|
|
4980
|
+
idx = @filtered_messages.index { |m| m.equal?(msg) }
|
|
4981
|
+
msg = full.merge('_full_loaded' => true)
|
|
4982
|
+
@filtered_messages[idx] = msg if idx
|
|
4983
|
+
else
|
|
4984
|
+
msg.merge!(full)
|
|
4985
|
+
msg['_full_loaded'] = true
|
|
4986
|
+
end
|
|
4987
|
+
end
|
|
4969
4988
|
end
|
|
4970
4989
|
msg
|
|
4971
4990
|
end
|
|
@@ -4975,7 +4994,7 @@ module Heathrow
|
|
|
4975
4994
|
def reply_to_message(force_editor: false)
|
|
4976
4995
|
msg = current_message
|
|
4977
4996
|
return unless msg
|
|
4978
|
-
ensure_full_message(msg)
|
|
4997
|
+
msg = ensure_full_message(msg)
|
|
4979
4998
|
|
|
4980
4999
|
# Don't allow replying to header messages
|
|
4981
5000
|
if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
|
|
@@ -5004,15 +5023,15 @@ module Heathrow
|
|
|
5004
5023
|
@panes[:bottom].refresh
|
|
5005
5024
|
|
|
5006
5025
|
composed = composer.compose_reply(false)
|
|
5026
|
+
setup_display
|
|
5027
|
+
create_panes
|
|
5028
|
+
render_all
|
|
5007
5029
|
if composed
|
|
5008
5030
|
finalize_compose(source, composed, "Reply cancelled")
|
|
5009
5031
|
else
|
|
5010
5032
|
set_feedback("Reply cancelled", 245, 1)
|
|
5011
5033
|
end
|
|
5012
5034
|
end
|
|
5013
|
-
|
|
5014
|
-
render_message_list
|
|
5015
|
-
render_bottom_bar
|
|
5016
5035
|
end
|
|
5017
5036
|
|
|
5018
5037
|
def chat_reply_context(msg, source_type)
|
|
@@ -5088,7 +5107,7 @@ module Heathrow
|
|
|
5088
5107
|
def reply_all_to_message
|
|
5089
5108
|
msg = current_message
|
|
5090
5109
|
return unless msg
|
|
5091
|
-
ensure_full_message(msg)
|
|
5110
|
+
msg = ensure_full_message(msg)
|
|
5092
5111
|
source_id = msg['source_id']
|
|
5093
5112
|
|
|
5094
5113
|
# Check if source supports replying
|
|
@@ -5106,20 +5125,21 @@ module Heathrow
|
|
|
5106
5125
|
# Compose the reply
|
|
5107
5126
|
composed = composer.compose_reply(true)
|
|
5108
5127
|
|
|
5128
|
+
setup_display
|
|
5129
|
+
create_panes
|
|
5130
|
+
render_all
|
|
5109
5131
|
if composed
|
|
5110
5132
|
finalize_compose(source, composed, "Reply-all cancelled")
|
|
5111
5133
|
else
|
|
5112
5134
|
set_feedback("Reply-all cancelled", 245, 1)
|
|
5113
5135
|
end
|
|
5114
|
-
|
|
5115
|
-
render_bottom_bar
|
|
5116
5136
|
end
|
|
5117
5137
|
|
|
5118
5138
|
def edit_message_content
|
|
5119
5139
|
msg = current_message
|
|
5120
5140
|
return unless msg
|
|
5121
5141
|
return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
|
|
5122
|
-
ensure_full_message(msg)
|
|
5142
|
+
msg = ensure_full_message(msg)
|
|
5123
5143
|
|
|
5124
5144
|
source_id = msg['source_id']
|
|
5125
5145
|
source = @db.get_source(source_id)
|
|
@@ -5147,7 +5167,7 @@ module Heathrow
|
|
|
5147
5167
|
def forward_message
|
|
5148
5168
|
msg = current_message
|
|
5149
5169
|
return unless msg
|
|
5150
|
-
ensure_full_message(msg)
|
|
5170
|
+
msg = ensure_full_message(msg)
|
|
5151
5171
|
source_id = msg['source_id']
|
|
5152
5172
|
|
|
5153
5173
|
# Check if source supports sending
|
|
@@ -5162,18 +5182,59 @@ module Heathrow
|
|
|
5162
5182
|
@panes[:bottom].text = " Opening editor to forward message...".fg(226)
|
|
5163
5183
|
@panes[:bottom].refresh
|
|
5164
5184
|
|
|
5185
|
+
# Extract attachments BEFORE editor
|
|
5186
|
+
orig_attachments = extract_original_attachments(msg)
|
|
5187
|
+
|
|
5165
5188
|
# Compose the forward
|
|
5166
5189
|
composed = composer.compose_forward
|
|
5167
5190
|
|
|
5191
|
+
# Rebuild panes after editor (vim clears the screen)
|
|
5192
|
+
setup_display
|
|
5193
|
+
create_panes
|
|
5194
|
+
render_all
|
|
5195
|
+
|
|
5168
5196
|
if composed
|
|
5197
|
+
# Include original attachments
|
|
5198
|
+
composed[:attachments] = orig_attachments if orig_attachments && !orig_attachments.empty?
|
|
5169
5199
|
finalize_compose(source, composed, "Forward cancelled")
|
|
5170
5200
|
else
|
|
5171
5201
|
set_feedback("Forward cancelled", 245, 1)
|
|
5172
5202
|
end
|
|
5173
|
-
|
|
5174
|
-
render_bottom_bar
|
|
5175
5203
|
end
|
|
5176
5204
|
|
|
5205
|
+
# Extract original attachments from a message for forwarding
|
|
5206
|
+
def extract_original_attachments(msg)
|
|
5207
|
+
metadata = msg['metadata']
|
|
5208
|
+
metadata = JSON.parse(metadata) if metadata.is_a?(String) rescue nil
|
|
5209
|
+
return nil unless metadata.is_a?(Hash)
|
|
5210
|
+
|
|
5211
|
+
file_path = metadata['maildir_file']
|
|
5212
|
+
return nil unless file_path && File.exist?(file_path)
|
|
5213
|
+
|
|
5214
|
+
require 'mail'
|
|
5215
|
+
require 'tmpdir'
|
|
5216
|
+
mail = Mail.read(file_path)
|
|
5217
|
+
return nil if mail.attachments.empty?
|
|
5218
|
+
|
|
5219
|
+
tmp_dir = File.join(Dir.tmpdir, "heathrow-fwd-#{Process.pid}")
|
|
5220
|
+
FileUtils.mkdir_p(tmp_dir)
|
|
5221
|
+
|
|
5222
|
+
paths = []
|
|
5223
|
+
mail.attachments.each do |att|
|
|
5224
|
+
next unless att.filename
|
|
5225
|
+
out = File.join(tmp_dir, att.filename)
|
|
5226
|
+
File.write(out, att.decoded)
|
|
5227
|
+
paths << out
|
|
5228
|
+
end
|
|
5229
|
+
paths.empty? ? nil : paths
|
|
5230
|
+
rescue => e
|
|
5231
|
+
File.open('/tmp/heathrow-crash.log', 'a') { |f|
|
|
5232
|
+
f.puts "#{Time.now.strftime('%Y-%m-%d %H:%M:%S')} extract_attachments: #{e.class}: #{e.message}"
|
|
5233
|
+
f.puts " #{e.backtrace&.first(3)&.join("\n ")}"
|
|
5234
|
+
}
|
|
5235
|
+
nil
|
|
5236
|
+
end
|
|
5237
|
+
|
|
5177
5238
|
def compose_new_message
|
|
5178
5239
|
# Build list of sendable channels from all DB sources
|
|
5179
5240
|
channels = []
|
|
@@ -5250,6 +5311,9 @@ module Heathrow
|
|
|
5250
5311
|
|
|
5251
5312
|
composed = composer.compose_new
|
|
5252
5313
|
end
|
|
5314
|
+
setup_display
|
|
5315
|
+
create_panes
|
|
5316
|
+
render_all
|
|
5253
5317
|
if composed
|
|
5254
5318
|
finalize_compose(source, composed, "Message cancelled")
|
|
5255
5319
|
else
|
|
@@ -5402,8 +5466,10 @@ module Heathrow
|
|
|
5402
5466
|
setup_display
|
|
5403
5467
|
create_panes
|
|
5404
5468
|
render_all
|
|
5469
|
+
pending_attachments = Array(composed[:attachments]).dup
|
|
5405
5470
|
loop do
|
|
5406
|
-
attachments = prompt_attachments(composed: composed)
|
|
5471
|
+
attachments = prompt_attachments(pending_attachments, composed: composed)
|
|
5472
|
+
pending_attachments = [] # Only seed on first iteration
|
|
5407
5473
|
case attachments
|
|
5408
5474
|
when :postpone
|
|
5409
5475
|
postpone_message(source, composed)
|
|
@@ -5622,14 +5688,14 @@ module Heathrow
|
|
|
5622
5688
|
msg += " (#{composed[:attachments].size} attachment(s))"
|
|
5623
5689
|
end
|
|
5624
5690
|
set_feedback(msg, 156, 3)
|
|
5691
|
+
render_left_pane if orig_id
|
|
5625
5692
|
else
|
|
5626
5693
|
set_feedback(result[:message], 196, 4)
|
|
5627
5694
|
end
|
|
5628
5695
|
rescue => e
|
|
5629
5696
|
set_feedback("Send error: #{e.message}", 196, 4)
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
end
|
|
5697
|
+
File.open('/tmp/heathrow_debug.log', 'a') { |f| f.puts "#{Time.now} send_composed_message error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
|
|
5698
|
+
|
|
5633
5699
|
end
|
|
5634
5700
|
end
|
|
5635
5701
|
end
|
|
@@ -7608,8 +7674,9 @@ Required: URL, optional CSS selector
|
|
|
7608
7674
|
def colorize_email_content(content)
|
|
7609
7675
|
quote_colors = [theme[:quote1] || 114, theme[:quote2] || 180,
|
|
7610
7676
|
theme[:quote3] || 139, theme[:quote4] || 109]
|
|
7611
|
-
sig_color = theme[:sig] ||
|
|
7612
|
-
link_color = theme[:
|
|
7677
|
+
sig_color = theme[:sig] || 5 # magenta (vim PreProc)
|
|
7678
|
+
link_color = theme[:link] || 4 # blue (vim String)
|
|
7679
|
+
email_color = theme[:email] || 5 # magenta (vim Special)
|
|
7613
7680
|
in_signature = false
|
|
7614
7681
|
|
|
7615
7682
|
content.lines.map do |line|
|
|
@@ -7719,7 +7786,7 @@ Required: URL, optional CSS selector
|
|
|
7719
7786
|
|
|
7720
7787
|
msg = current_message
|
|
7721
7788
|
return unless msg
|
|
7722
|
-
ensure_full_message(msg)
|
|
7789
|
+
msg = ensure_full_message(msg)
|
|
7723
7790
|
|
|
7724
7791
|
http_urls = []
|
|
7725
7792
|
|
data/lib/heathrow/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: heathrow
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Geir Isene
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-03-
|
|
12
|
+
date: 2026-03-18 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: rcurses
|