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,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
|