sup 0.19.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 +17 -0
- data/.travis.yml +12 -0
- data/CONTRIBUTORS +84 -0
- data/Gemfile +3 -0
- data/HACKING +42 -0
- data/History.txt +361 -0
- data/LICENSE +280 -0
- data/README.md +70 -0
- data/Rakefile +12 -0
- data/ReleaseNotes +231 -0
- data/bin/sup +434 -0
- data/bin/sup-add +118 -0
- data/bin/sup-config +243 -0
- data/bin/sup-dump +43 -0
- data/bin/sup-import-dump +101 -0
- data/bin/sup-psych-ify-config-files +21 -0
- data/bin/sup-recover-sources +87 -0
- data/bin/sup-sync +210 -0
- data/bin/sup-sync-back-maildir +127 -0
- data/bin/sup-tweak-labels +140 -0
- data/contrib/colorpicker.rb +100 -0
- data/contrib/completion/_sup.zsh +114 -0
- data/devel/console.sh +3 -0
- data/devel/count-loc.sh +3 -0
- data/devel/load-index.rb +9 -0
- data/devel/profile.rb +12 -0
- data/devel/start-console.rb +5 -0
- data/doc/FAQ.txt +119 -0
- data/doc/Hooks.txt +79 -0
- data/doc/Philosophy.txt +69 -0
- data/lib/sup.rb +467 -0
- data/lib/sup/account.rb +90 -0
- data/lib/sup/buffer.rb +768 -0
- data/lib/sup/colormap.rb +239 -0
- data/lib/sup/contact.rb +67 -0
- data/lib/sup/crypto.rb +461 -0
- data/lib/sup/draft.rb +119 -0
- data/lib/sup/hook.rb +159 -0
- data/lib/sup/horizontal_selector.rb +59 -0
- data/lib/sup/idle.rb +42 -0
- data/lib/sup/index.rb +882 -0
- data/lib/sup/interactive_lock.rb +89 -0
- data/lib/sup/keymap.rb +140 -0
- data/lib/sup/label.rb +87 -0
- data/lib/sup/logger.rb +77 -0
- data/lib/sup/logger/singleton.rb +10 -0
- data/lib/sup/maildir.rb +257 -0
- data/lib/sup/mbox.rb +187 -0
- data/lib/sup/message.rb +803 -0
- data/lib/sup/message_chunks.rb +328 -0
- data/lib/sup/mode.rb +140 -0
- data/lib/sup/modes/buffer_list_mode.rb +50 -0
- data/lib/sup/modes/completion_mode.rb +55 -0
- data/lib/sup/modes/compose_mode.rb +38 -0
- data/lib/sup/modes/console_mode.rb +125 -0
- data/lib/sup/modes/contact_list_mode.rb +148 -0
- data/lib/sup/modes/edit_message_async_mode.rb +110 -0
- data/lib/sup/modes/edit_message_mode.rb +728 -0
- data/lib/sup/modes/file_browser_mode.rb +109 -0
- data/lib/sup/modes/forward_mode.rb +82 -0
- data/lib/sup/modes/help_mode.rb +19 -0
- data/lib/sup/modes/inbox_mode.rb +85 -0
- data/lib/sup/modes/label_list_mode.rb +138 -0
- data/lib/sup/modes/label_search_results_mode.rb +38 -0
- data/lib/sup/modes/line_cursor_mode.rb +203 -0
- data/lib/sup/modes/log_mode.rb +57 -0
- data/lib/sup/modes/person_search_results_mode.rb +12 -0
- data/lib/sup/modes/poll_mode.rb +19 -0
- data/lib/sup/modes/reply_mode.rb +228 -0
- data/lib/sup/modes/resume_mode.rb +52 -0
- data/lib/sup/modes/scroll_mode.rb +252 -0
- data/lib/sup/modes/search_list_mode.rb +204 -0
- data/lib/sup/modes/search_results_mode.rb +59 -0
- data/lib/sup/modes/text_mode.rb +76 -0
- data/lib/sup/modes/thread_index_mode.rb +1033 -0
- data/lib/sup/modes/thread_view_mode.rb +941 -0
- data/lib/sup/person.rb +134 -0
- data/lib/sup/poll.rb +272 -0
- data/lib/sup/rfc2047.rb +56 -0
- data/lib/sup/search.rb +110 -0
- data/lib/sup/sent.rb +58 -0
- data/lib/sup/service/label_service.rb +45 -0
- data/lib/sup/source.rb +244 -0
- data/lib/sup/tagger.rb +50 -0
- data/lib/sup/textfield.rb +253 -0
- data/lib/sup/thread.rb +452 -0
- data/lib/sup/time.rb +93 -0
- data/lib/sup/undo.rb +38 -0
- data/lib/sup/update.rb +30 -0
- data/lib/sup/util.rb +747 -0
- data/lib/sup/util/ncurses.rb +274 -0
- data/lib/sup/util/path.rb +9 -0
- data/lib/sup/util/query.rb +17 -0
- data/lib/sup/util/uri.rb +15 -0
- data/lib/sup/version.rb +3 -0
- data/sup.gemspec +53 -0
- data/test/dummy_source.rb +61 -0
- data/test/gnupg_test_home/gpg.conf +1 -0
- data/test/gnupg_test_home/pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_secring.gpg +0 -0
- data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
- data/test/gnupg_test_home/secring.gpg +0 -0
- data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
- data/test/gnupg_test_home/trustdb.gpg +0 -0
- data/test/integration/test_label_service.rb +18 -0
- data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
- data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
- data/test/messages/missing-line.eml +9 -0
- data/test/test_crypto.rb +109 -0
- data/test/test_header_parsing.rb +168 -0
- data/test/test_helper.rb +7 -0
- data/test/test_message.rb +532 -0
- data/test/test_messages_dir.rb +147 -0
- data/test/test_yaml_migration.rb +85 -0
- data/test/test_yaml_regressions.rb +17 -0
- data/test/unit/service/test_label_service.rb +19 -0
- data/test/unit/test_horizontal_selector.rb +40 -0
- data/test/unit/util/test_query.rb +46 -0
- data/test/unit/util/test_string.rb +57 -0
- data/test/unit/util/test_uri.rb +19 -0
- metadata +423 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
require 'tempfile'
|
|
2
|
+
require 'socket' # just for gethostname!
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Redwood
|
|
6
|
+
|
|
7
|
+
class SendmailCommandFailed < StandardError; end
|
|
8
|
+
|
|
9
|
+
class EditMessageMode < LineCursorMode
|
|
10
|
+
DECORATION_LINES = 1
|
|
11
|
+
|
|
12
|
+
FORCE_HEADERS = %w(From To Cc Bcc Subject)
|
|
13
|
+
MULTI_HEADERS = %w(To Cc Bcc)
|
|
14
|
+
NON_EDITABLE_HEADERS = %w(Message-id Date)
|
|
15
|
+
|
|
16
|
+
HookManager.register "signature", <<EOS
|
|
17
|
+
Generates a message signature.
|
|
18
|
+
Variables:
|
|
19
|
+
header: an object that supports string-to-string hashtable-style access
|
|
20
|
+
to the raw headers for the message. E.g., header["From"],
|
|
21
|
+
header["To"], etc.
|
|
22
|
+
from_email: the email part of the From: line, or nil if empty
|
|
23
|
+
Return value:
|
|
24
|
+
A string (multi-line ok) containing the text of the signature, or nil to
|
|
25
|
+
use the default signature, or :none for no signature.
|
|
26
|
+
EOS
|
|
27
|
+
|
|
28
|
+
HookManager.register "check-attachment", <<EOS
|
|
29
|
+
Do checks on the attachment filename
|
|
30
|
+
Variables:
|
|
31
|
+
filename: the name of the attachment
|
|
32
|
+
Return value:
|
|
33
|
+
A String (single line) containing a message why this attachment is not optimal
|
|
34
|
+
to be attached.
|
|
35
|
+
If it is ok just return an empty string or nil
|
|
36
|
+
EOS
|
|
37
|
+
|
|
38
|
+
HookManager.register "before-edit", <<EOS
|
|
39
|
+
Modifies message body and headers before editing a new message. Variables
|
|
40
|
+
should be modified in place.
|
|
41
|
+
Variables:
|
|
42
|
+
header: a hash of headers. See 'signature' hook for documentation.
|
|
43
|
+
body: an array of lines of body text.
|
|
44
|
+
Return value:
|
|
45
|
+
none
|
|
46
|
+
EOS
|
|
47
|
+
|
|
48
|
+
HookManager.register "mentions-attachments", <<EOS
|
|
49
|
+
Detects if given message mentions attachments the way it is probable
|
|
50
|
+
that there should be files attached to the message.
|
|
51
|
+
Variables:
|
|
52
|
+
header: a hash of headers. See 'signature' hook for documentation.
|
|
53
|
+
body: an array of lines of body text.
|
|
54
|
+
Return value:
|
|
55
|
+
True if attachments are mentioned.
|
|
56
|
+
EOS
|
|
57
|
+
|
|
58
|
+
HookManager.register "crypto-mode", <<EOS
|
|
59
|
+
Modifies cryptography settings based on header and message content, before
|
|
60
|
+
editing a new message. This can be used to set, for example, default cryptography
|
|
61
|
+
settings.
|
|
62
|
+
Variables:
|
|
63
|
+
header: a hash of headers. See 'signature' hook for documentation.
|
|
64
|
+
body: an array of lines of body text.
|
|
65
|
+
crypto_selector: the UI element that controls the current cryptography setting.
|
|
66
|
+
Return value:
|
|
67
|
+
none
|
|
68
|
+
EOS
|
|
69
|
+
|
|
70
|
+
HookManager.register "sendmail", <<EOS
|
|
71
|
+
Sends the given mail. If this hook doesn't exist, the sendmail command
|
|
72
|
+
configured for the account is used.
|
|
73
|
+
The message will be saved after this hook is run, so any modification to it
|
|
74
|
+
will be recorded.
|
|
75
|
+
Variables:
|
|
76
|
+
message: RMail::Message instance of the mail to send
|
|
77
|
+
account: Account instance matching the From address
|
|
78
|
+
Return value:
|
|
79
|
+
True if mail has been sent successfully, false otherwise.
|
|
80
|
+
EOS
|
|
81
|
+
|
|
82
|
+
attr_reader :status
|
|
83
|
+
attr_accessor :body, :header
|
|
84
|
+
bool_reader :edited
|
|
85
|
+
|
|
86
|
+
register_keymap do |k|
|
|
87
|
+
k.add :send_message, "Send message", 'y'
|
|
88
|
+
k.add :edit_message_or_field, "Edit selected field", 'e'
|
|
89
|
+
k.add :edit_to, "Edit To:", 't'
|
|
90
|
+
k.add :edit_cc, "Edit Cc:", 'c'
|
|
91
|
+
k.add :edit_subject, "Edit Subject", 's'
|
|
92
|
+
k.add :default_edit_message, "Edit message (default)", :enter
|
|
93
|
+
k.add :alternate_edit_message, "Edit message (alternate, asynchronously)", 'E'
|
|
94
|
+
k.add :save_as_draft, "Save as draft", 'P'
|
|
95
|
+
k.add :attach_file, "Attach a file", 'a'
|
|
96
|
+
k.add :delete_attachment, "Delete an attachment", 'd'
|
|
97
|
+
k.add :move_cursor_right, "Move selector to the right", :right, 'l'
|
|
98
|
+
k.add :move_cursor_left, "Move selector to the left", :left, 'h'
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def initialize opts={}
|
|
102
|
+
@header = opts.delete(:header) || {}
|
|
103
|
+
@header_lines = []
|
|
104
|
+
|
|
105
|
+
@body = opts.delete(:body) || []
|
|
106
|
+
|
|
107
|
+
if opts[:attachments]
|
|
108
|
+
@attachments = opts[:attachments].values
|
|
109
|
+
@attachment_names = opts[:attachments].keys
|
|
110
|
+
else
|
|
111
|
+
@attachments = []
|
|
112
|
+
@attachment_names = []
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
hostname = File.open("/etc/mailname", "r").gets.chomp
|
|
117
|
+
rescue
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
hostname = Socket.gethostname if hostname.nil? or hostname.empty?
|
|
121
|
+
|
|
122
|
+
@message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{hostname}>"
|
|
123
|
+
@edited = false
|
|
124
|
+
@sig_edited = false
|
|
125
|
+
@selectors = []
|
|
126
|
+
@selector_label_width = 0
|
|
127
|
+
@async_mode = nil
|
|
128
|
+
|
|
129
|
+
HookManager.run "before-edit", :header => @header, :body => @body
|
|
130
|
+
|
|
131
|
+
@account_selector = nil
|
|
132
|
+
# only show account selector if there is more than one email address
|
|
133
|
+
if $config[:account_selector] && AccountManager.user_emails.length > 1
|
|
134
|
+
## Duplicate e-mail strings to prevent a "can't modify frozen
|
|
135
|
+
## object" crash triggered by the String::display_length()
|
|
136
|
+
## method in util.rb
|
|
137
|
+
user_emails_copy = []
|
|
138
|
+
AccountManager.user_emails.each { |e| user_emails_copy.push e.dup }
|
|
139
|
+
|
|
140
|
+
@account_selector =
|
|
141
|
+
HorizontalSelector.new "Account:", AccountManager.user_emails + [nil], user_emails_copy + ["Customized"]
|
|
142
|
+
|
|
143
|
+
if @header["From"] =~ /<?(\S+@(\S+?))>?$/
|
|
144
|
+
# TODO: this is ugly. might implement an AccountSelector and handle
|
|
145
|
+
# special cases more transparently.
|
|
146
|
+
account_from = @account_selector.can_set_to?($1) ? $1 : nil
|
|
147
|
+
@account_selector.set_to account_from
|
|
148
|
+
else
|
|
149
|
+
@account_selector.set_to nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A single source of truth might better than duplicating this in both
|
|
153
|
+
# @account_user and @account_selector.
|
|
154
|
+
@account_user = @header["From"]
|
|
155
|
+
|
|
156
|
+
add_selector @account_selector
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@crypto_selector =
|
|
160
|
+
if CryptoManager.have_crypto?
|
|
161
|
+
HorizontalSelector.new "Crypto:", [:none] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.keys, ["None"] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.values
|
|
162
|
+
end
|
|
163
|
+
add_selector @crypto_selector if @crypto_selector
|
|
164
|
+
|
|
165
|
+
if @crypto_selector
|
|
166
|
+
HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
super opts
|
|
170
|
+
regen_text
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def lines; @text.length + (@selectors.empty? ? 0 : (@selectors.length + DECORATION_LINES)) end
|
|
174
|
+
|
|
175
|
+
def [] i
|
|
176
|
+
if @selectors.empty?
|
|
177
|
+
@text[i]
|
|
178
|
+
elsif i < @selectors.length
|
|
179
|
+
@selectors[i].line @selector_label_width
|
|
180
|
+
elsif i == @selectors.length
|
|
181
|
+
""
|
|
182
|
+
else
|
|
183
|
+
@text[i - @selectors.length - DECORATION_LINES]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
## hook for subclasses. i hate this style of programming.
|
|
188
|
+
def handle_new_text header, body; end
|
|
189
|
+
|
|
190
|
+
def edit_message_or_field
|
|
191
|
+
lines = (@selectors.empty? ? 0 : DECORATION_LINES) + @selectors.size
|
|
192
|
+
if lines > curpos
|
|
193
|
+
return
|
|
194
|
+
elsif (curpos - lines) >= @header_lines.length
|
|
195
|
+
default_edit_message
|
|
196
|
+
else
|
|
197
|
+
edit_field @header_lines[curpos - lines]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def edit_to; edit_field "To" end
|
|
202
|
+
def edit_cc; edit_field "Cc" end
|
|
203
|
+
def edit_subject; edit_field "Subject" end
|
|
204
|
+
|
|
205
|
+
def save_message_to_file
|
|
206
|
+
sig = sig_lines.join("\n")
|
|
207
|
+
@file = Tempfile.new ["sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}", ".eml"]
|
|
208
|
+
@file.puts format_headers(@header - NON_EDITABLE_HEADERS).first
|
|
209
|
+
@file.puts
|
|
210
|
+
|
|
211
|
+
begin
|
|
212
|
+
text = @body.join("\n")
|
|
213
|
+
rescue Encoding::CompatibilityError
|
|
214
|
+
text = @body.map { |x| x.fix_encoding! }.join("\n")
|
|
215
|
+
debug "encoding problem while writing message, trying to rescue, but expect errors: #{text}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
@file.puts text
|
|
219
|
+
@file.puts sig if ($config[:edit_signature] and !@sig_edited)
|
|
220
|
+
@file.close
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def set_sig_edit_flag
|
|
224
|
+
sig = sig_lines.join("\n")
|
|
225
|
+
if $config[:edit_signature]
|
|
226
|
+
pbody = @body.map { |x| x.fix_encoding! }.join("\n").fix_encoding!
|
|
227
|
+
blen = pbody.length
|
|
228
|
+
slen = sig.length
|
|
229
|
+
|
|
230
|
+
if blen > slen and pbody[blen-slen..blen] == sig
|
|
231
|
+
@sig_edited = false
|
|
232
|
+
@body = pbody[0..blen-slen].fix_encoding!.split("\n")
|
|
233
|
+
else
|
|
234
|
+
@sig_edited = true
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def default_edit_message
|
|
240
|
+
if $config[:always_edit_async]
|
|
241
|
+
return edit_message_async
|
|
242
|
+
else
|
|
243
|
+
return edit_message
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def alternate_edit_message
|
|
248
|
+
if $config[:always_edit_async]
|
|
249
|
+
return edit_message
|
|
250
|
+
else
|
|
251
|
+
return edit_message_async
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def edit_message
|
|
256
|
+
old_from = @header["From"] if @account_selector
|
|
257
|
+
|
|
258
|
+
begin
|
|
259
|
+
save_message_to_file
|
|
260
|
+
rescue SystemCallError => e
|
|
261
|
+
BufferManager.flash "Can't save message to file: #{e.message}"
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
editor = $config[:editor] || ENV['EDITOR'] || "/usr/bin/vi"
|
|
266
|
+
|
|
267
|
+
mtime = File.mtime @file.path
|
|
268
|
+
BufferManager.shell_out "#{editor} #{@file.path}"
|
|
269
|
+
@edited = true if File.mtime(@file.path) > mtime
|
|
270
|
+
|
|
271
|
+
return @edited unless @edited
|
|
272
|
+
|
|
273
|
+
header, @body = parse_file @file.path
|
|
274
|
+
@header = header - NON_EDITABLE_HEADERS
|
|
275
|
+
set_sig_edit_flag
|
|
276
|
+
|
|
277
|
+
if @account_selector and @header["From"] != old_from
|
|
278
|
+
@account_user = @header["From"]
|
|
279
|
+
@account_selector.set_to nil
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
handle_new_text @header, @body
|
|
283
|
+
rerun_crypto_selector_hook
|
|
284
|
+
update
|
|
285
|
+
|
|
286
|
+
@edited
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def edit_message_async
|
|
290
|
+
begin
|
|
291
|
+
save_message_to_file
|
|
292
|
+
rescue SystemCallError => e
|
|
293
|
+
BufferManager.flash "Can't save message to file: #{e.message}"
|
|
294
|
+
return
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
@mtime = File.mtime @file.path
|
|
298
|
+
|
|
299
|
+
# put up buffer saying you can now edit the message in another
|
|
300
|
+
# terminal or app, and continue to use sup in the meantime.
|
|
301
|
+
subject = @header["Subject"] || ""
|
|
302
|
+
@async_mode = EditMessageAsyncMode.new self, @file.path, subject
|
|
303
|
+
BufferManager.spawn "Waiting for message \"#{subject}\" to be finished", @async_mode
|
|
304
|
+
|
|
305
|
+
# hide ourselves, and wait for signal to resume from async mode ...
|
|
306
|
+
buffer.hidden = true
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def edit_message_async_resume being_killed=false
|
|
310
|
+
buffer.hidden = false
|
|
311
|
+
@async_mode = nil
|
|
312
|
+
BufferManager.raise_to_front buffer if !being_killed
|
|
313
|
+
|
|
314
|
+
@edited = true if File.mtime(@file.path) > @mtime
|
|
315
|
+
|
|
316
|
+
header, @body = parse_file @file.path
|
|
317
|
+
@header = header - NON_EDITABLE_HEADERS
|
|
318
|
+
set_sig_edit_flag
|
|
319
|
+
handle_new_text @header, @body
|
|
320
|
+
update
|
|
321
|
+
|
|
322
|
+
true
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def killable?
|
|
326
|
+
if !@async_mode.nil?
|
|
327
|
+
return false if !@async_mode.killable?
|
|
328
|
+
if File.mtime(@file.path) > @mtime
|
|
329
|
+
@edited = true
|
|
330
|
+
header, @body = parse_file @file.path
|
|
331
|
+
@header = header - NON_EDITABLE_HEADERS
|
|
332
|
+
handle_new_text @header, @body
|
|
333
|
+
update
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
!edited? || BufferManager.ask_yes_or_no("Discard message?")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def unsaved?; edited? end
|
|
340
|
+
|
|
341
|
+
def attach_file
|
|
342
|
+
fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
|
|
343
|
+
return unless fn
|
|
344
|
+
if HookManager.enabled? "check-attachment"
|
|
345
|
+
reason = HookManager.run("check-attachment", :filename => fn)
|
|
346
|
+
if reason
|
|
347
|
+
return unless BufferManager.ask_yes_or_no("#{reason} Attach anyway?")
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
begin
|
|
351
|
+
Dir[fn].each do |f|
|
|
352
|
+
@attachments << RMail::Message.make_file_attachment(f)
|
|
353
|
+
@attachment_names << f
|
|
354
|
+
end
|
|
355
|
+
update
|
|
356
|
+
rescue SystemCallError => e
|
|
357
|
+
BufferManager.flash "Can't read #{fn}: #{e.message}"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def delete_attachment
|
|
362
|
+
i = curpos - @attachment_lines_offset - (@selectors.empty? ? 0 : DECORATION_LINES) - @selectors.size
|
|
363
|
+
if i >= 0 && i < @attachments.size && BufferManager.ask_yes_or_no("Delete attachment #{@attachment_names[i]}?")
|
|
364
|
+
@attachments.delete_at i
|
|
365
|
+
@attachment_names.delete_at i
|
|
366
|
+
update
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
protected
|
|
371
|
+
|
|
372
|
+
def rerun_crypto_selector_hook
|
|
373
|
+
if @crypto_selector && !@crypto_selector.changed_by_user
|
|
374
|
+
HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def mime_encode string
|
|
379
|
+
string = [string].pack('M') # basic quoted-printable
|
|
380
|
+
string.gsub!(/=\n/,'') # .. remove trailing newline
|
|
381
|
+
string.gsub!(/_/,'=5F') # .. encode underscores
|
|
382
|
+
string.gsub!(/\?/,'=3F') # .. encode question marks
|
|
383
|
+
string.gsub!(/ /,'_') # .. translate space to underscores
|
|
384
|
+
"=?utf-8?q?#{string}?="
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def mime_encode_subject string
|
|
388
|
+
return string if string.ascii_only?
|
|
389
|
+
mime_encode string
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
RE_ADDRESS = /(.+)( <.*@.*>)/
|
|
393
|
+
|
|
394
|
+
# Encode "bælammet mitt <user@example.com>" into
|
|
395
|
+
# "=?utf-8?q?b=C3=A6lammet_mitt?= <user@example.com>
|
|
396
|
+
def mime_encode_address string
|
|
397
|
+
return string if string.ascii_only?
|
|
398
|
+
string.sub(RE_ADDRESS) { |match| mime_encode($1) + $2 }
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def move_cursor_left
|
|
402
|
+
if curpos < @selectors.length
|
|
403
|
+
@selectors[curpos].roll_left
|
|
404
|
+
buffer.mark_dirty
|
|
405
|
+
update if @account_selector
|
|
406
|
+
else
|
|
407
|
+
col_left
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def move_cursor_right
|
|
412
|
+
if curpos < @selectors.length
|
|
413
|
+
@selectors[curpos].roll_right
|
|
414
|
+
buffer.mark_dirty
|
|
415
|
+
update if @account_selector
|
|
416
|
+
else
|
|
417
|
+
col_right
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def add_selector s
|
|
422
|
+
@selectors << s
|
|
423
|
+
@selector_label_width = [@selector_label_width, s.label.length].max
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def update
|
|
427
|
+
if @account_selector
|
|
428
|
+
if @account_selector.val.nil?
|
|
429
|
+
@header["From"] = @account_user
|
|
430
|
+
else
|
|
431
|
+
@header["From"] = AccountManager.full_address_for @account_selector.val
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
regen_text
|
|
436
|
+
buffer.mark_dirty if buffer
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def regen_text
|
|
440
|
+
header, @header_lines = format_headers(@header - NON_EDITABLE_HEADERS) + [""]
|
|
441
|
+
@text = header + [""] + @body
|
|
442
|
+
@text += sig_lines unless @sig_edited
|
|
443
|
+
|
|
444
|
+
@attachment_lines_offset = 0
|
|
445
|
+
|
|
446
|
+
unless @attachments.empty?
|
|
447
|
+
@text += [""]
|
|
448
|
+
@attachment_lines_offset = @text.length
|
|
449
|
+
@text += (0 ... @attachments.size).map { |i| [[:attachment_color, "+ Attachment: #{@attachment_names[i]} (#{@attachments[i].body.size.to_human_size})"]] }
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def parse_file fn
|
|
454
|
+
File.open(fn) do |f|
|
|
455
|
+
header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
|
|
456
|
+
body = f.readlines.map { |l| l.chomp }
|
|
457
|
+
|
|
458
|
+
header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
|
|
459
|
+
header.each { |k, v| header[k] = parse_header k, v }
|
|
460
|
+
|
|
461
|
+
[header, body]
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def parse_header k, v
|
|
466
|
+
if MULTI_HEADERS.include?(k)
|
|
467
|
+
v.split_on_commas.map do |name|
|
|
468
|
+
(p = ContactManager.contact_for(name)) && p.full_address || name
|
|
469
|
+
end
|
|
470
|
+
else
|
|
471
|
+
v
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def format_headers header
|
|
476
|
+
header_lines = []
|
|
477
|
+
headers = (FORCE_HEADERS + (header.keys - FORCE_HEADERS)).map do |h|
|
|
478
|
+
lines = make_lines "#{h}:", header[h]
|
|
479
|
+
lines.length.times { header_lines << h }
|
|
480
|
+
lines
|
|
481
|
+
end.flatten.compact
|
|
482
|
+
[headers, header_lines]
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def make_lines header, things
|
|
486
|
+
case things
|
|
487
|
+
when nil, []
|
|
488
|
+
[header + " "]
|
|
489
|
+
when String
|
|
490
|
+
[header + " " + things]
|
|
491
|
+
else
|
|
492
|
+
if things.empty?
|
|
493
|
+
[header]
|
|
494
|
+
else
|
|
495
|
+
things.map_with_index do |name, i|
|
|
496
|
+
raise "an array: #{name.inspect} (things #{things.inspect})" if Array === name
|
|
497
|
+
if i == 0
|
|
498
|
+
header + " " + name
|
|
499
|
+
else
|
|
500
|
+
(" " * (header.display_length + 1)) + name
|
|
501
|
+
end + (i == things.length - 1 ? "" : ",")
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def send_message
|
|
508
|
+
return false if !edited? && !BufferManager.ask_yes_or_no("Message unedited. Really send?")
|
|
509
|
+
return false if $config[:confirm_no_attachments] && mentions_attachments? && @attachments.size == 0 && !BufferManager.ask_yes_or_no("You haven't added any attachments. Really send?")#" stupid ruby-mode
|
|
510
|
+
return false if $config[:confirm_top_posting] && top_posting? && !BufferManager.ask_yes_or_no("You're top-posting. That makes you a bad person. Really send?") #" stupid ruby-mode
|
|
511
|
+
|
|
512
|
+
from_email =
|
|
513
|
+
if @header["From"] =~ /<?(\S+@(\S+?))>?$/
|
|
514
|
+
$1
|
|
515
|
+
else
|
|
516
|
+
AccountManager.default_account.email
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
acct = AccountManager.account_for(from_email) || AccountManager.default_account
|
|
520
|
+
BufferManager.flash "Sending..."
|
|
521
|
+
|
|
522
|
+
begin
|
|
523
|
+
date = Time.now
|
|
524
|
+
m = build_message date
|
|
525
|
+
|
|
526
|
+
if HookManager.enabled? "sendmail"
|
|
527
|
+
if not HookManager.run "sendmail", :message => m, :account => acct
|
|
528
|
+
warn "Sendmail hook was not successful"
|
|
529
|
+
return false
|
|
530
|
+
end
|
|
531
|
+
else
|
|
532
|
+
IO.popen(acct.sendmail, "w:UTF-8") { |p| p.puts m }
|
|
533
|
+
raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
SentManager.write_sent_message(date, from_email) { |f| f.puts sanitize_body(m.to_s) }
|
|
537
|
+
BufferManager.kill_buffer buffer
|
|
538
|
+
BufferManager.flash "Message sent!"
|
|
539
|
+
true
|
|
540
|
+
rescue SystemCallError, SendmailCommandFailed, CryptoManager::Error => e
|
|
541
|
+
warn "Problem sending mail: #{e.message}"
|
|
542
|
+
BufferManager.flash "Problem sending mail: #{e.message}"
|
|
543
|
+
false
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def save_as_draft
|
|
548
|
+
DraftManager.write_draft { |f| write_message f, false }
|
|
549
|
+
BufferManager.kill_buffer buffer
|
|
550
|
+
BufferManager.flash "Saved for later editing."
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def build_message date
|
|
554
|
+
m = RMail::Message.new
|
|
555
|
+
m.header["Content-Type"] = "text/plain; charset=#{$encoding}"
|
|
556
|
+
m.body = @body.join("\n")
|
|
557
|
+
m.body += "\n" + sig_lines.join("\n") unless @sig_edited
|
|
558
|
+
## body must end in a newline or GPG signatures will be WRONG!
|
|
559
|
+
m.body += "\n" unless m.body =~ /\n\Z/
|
|
560
|
+
m.body = m.body.fix_encoding!
|
|
561
|
+
|
|
562
|
+
## there are attachments, so wrap body in an attachment of its own
|
|
563
|
+
unless @attachments.empty?
|
|
564
|
+
body_m = m
|
|
565
|
+
body_m.header["Content-Disposition"] = "inline"
|
|
566
|
+
m = RMail::Message.new
|
|
567
|
+
|
|
568
|
+
m.add_part body_m
|
|
569
|
+
@attachments.each do |a|
|
|
570
|
+
a.body = a.body.fix_encoding! if a.body.kind_of? String
|
|
571
|
+
m.add_part a
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
## do whatever crypto transformation is necessary
|
|
576
|
+
if @crypto_selector && @crypto_selector.val != :none
|
|
577
|
+
from_email = Person.from_address(@header["From"]).email
|
|
578
|
+
to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
|
|
579
|
+
if m.multipart?
|
|
580
|
+
m.each_part {|p| p = transfer_encode p}
|
|
581
|
+
else
|
|
582
|
+
m = transfer_encode m
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
## finally, set the top-level headers
|
|
589
|
+
@header.each do |k, v|
|
|
590
|
+
next if v.nil? || v.empty?
|
|
591
|
+
m.header[k] =
|
|
592
|
+
case v
|
|
593
|
+
when String
|
|
594
|
+
(k.match(/subject/i) ? mime_encode_subject(v).dup.fix_encoding! : mime_encode_address(v)).dup.fix_encoding!
|
|
595
|
+
when Array
|
|
596
|
+
(v.map { |v| mime_encode_address v }.join ", ").dup.fix_encoding!
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
m.header["Date"] = date.rfc2822
|
|
601
|
+
m.header["Message-Id"] = @message_id
|
|
602
|
+
m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
|
|
603
|
+
m.header["Content-Transfer-Encoding"] ||= '8bit'
|
|
604
|
+
m.header["MIME-Version"] = "1.0" if m.multipart?
|
|
605
|
+
m
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
## TODO: remove this. redundant with write_full_message_to.
|
|
609
|
+
##
|
|
610
|
+
## this is going to change soon: draft messages (currently written
|
|
611
|
+
## with full=false) will be output as yaml.
|
|
612
|
+
def write_message f, full=true, date=Time.now
|
|
613
|
+
raise ArgumentError, "no pre-defined date: header allowed" if @header["Date"]
|
|
614
|
+
f.puts format_headers(@header).first
|
|
615
|
+
f.puts <<EOS
|
|
616
|
+
Date: #{date.rfc2822}
|
|
617
|
+
Message-Id: #{@message_id}
|
|
618
|
+
EOS
|
|
619
|
+
if full
|
|
620
|
+
f.puts <<EOS
|
|
621
|
+
Mime-Version: 1.0
|
|
622
|
+
Content-Type: text/plain; charset=us-ascii
|
|
623
|
+
Content-Disposition: inline
|
|
624
|
+
User-Agent: Redwood/#{Redwood::VERSION}
|
|
625
|
+
EOS
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
f.puts
|
|
629
|
+
f.puts sanitize_body(@body.join("\n"))
|
|
630
|
+
f.puts sig_lines if full unless $config[:edit_signature]
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
protected
|
|
634
|
+
|
|
635
|
+
def edit_field field
|
|
636
|
+
case field
|
|
637
|
+
when "Subject"
|
|
638
|
+
text = BufferManager.ask :subject, "Subject: ", @header[field]
|
|
639
|
+
if text
|
|
640
|
+
@header[field] = parse_header field, text
|
|
641
|
+
update
|
|
642
|
+
end
|
|
643
|
+
else
|
|
644
|
+
default = case field
|
|
645
|
+
when *MULTI_HEADERS
|
|
646
|
+
@header[field] ||= []
|
|
647
|
+
@header[field].join(", ")
|
|
648
|
+
else
|
|
649
|
+
@header[field]
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
|
|
653
|
+
if contacts
|
|
654
|
+
text = contacts.map { |s| s.full_address }.join(", ")
|
|
655
|
+
@header[field] = parse_header field, text
|
|
656
|
+
|
|
657
|
+
if @account_selector and field == "From"
|
|
658
|
+
@account_user = @header["From"]
|
|
659
|
+
@account_selector.set_to nil
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
rerun_crypto_selector_hook
|
|
663
|
+
update
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
private
|
|
669
|
+
|
|
670
|
+
def sanitize_body body
|
|
671
|
+
body.gsub(/^From /, ">From ")
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def mentions_attachments?
|
|
675
|
+
if HookManager.enabled? "mentions-attachments"
|
|
676
|
+
HookManager.run "mentions-attachments", :header => @header, :body => @body
|
|
677
|
+
else
|
|
678
|
+
@body.any? { |l| l.fix_encoding! =~ /^[^>]/ && l.fix_encoding! =~ /\battach(ment|ed|ing|)\b/i }
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def top_posting?
|
|
683
|
+
@body.map { |x| x.fix_encoding! }.join("\n").fix_encoding! =~ /(\S+)\s*Excerpts from.*\n(>.*\n)+\s*\Z/
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def sig_lines
|
|
687
|
+
p = Person.from_address(@header["From"])
|
|
688
|
+
from_email = p && p.email
|
|
689
|
+
|
|
690
|
+
## first run the hook
|
|
691
|
+
hook_sig = HookManager.run "signature", :header => @header, :from_email => from_email
|
|
692
|
+
|
|
693
|
+
return [] if hook_sig == :none
|
|
694
|
+
return ["", "-- "] + hook_sig.split("\n") if hook_sig
|
|
695
|
+
|
|
696
|
+
## no hook, do default signature generation based on config.yaml
|
|
697
|
+
return [] unless from_email
|
|
698
|
+
sigfn = (AccountManager.account_for(from_email) ||
|
|
699
|
+
AccountManager.default_account).signature
|
|
700
|
+
|
|
701
|
+
if sigfn && File.exists?(sigfn)
|
|
702
|
+
["", "-- "] + File.readlines(sigfn).map { |l| l.chomp }
|
|
703
|
+
else
|
|
704
|
+
[]
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def transfer_encode msg_part
|
|
709
|
+
## return the message unchanged if it's already encoded
|
|
710
|
+
if (msg_part.header["Content-Transfer-Encoding"] == "base64" ||
|
|
711
|
+
msg_part.header["Content-Transfer-Encoding"] == "quoted-printable")
|
|
712
|
+
return msg_part
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
## encode to quoted-printable for all text/* MIME types,
|
|
716
|
+
## use base64 otherwise
|
|
717
|
+
if msg_part.header["Content-Type"] =~ /text\/.*/
|
|
718
|
+
msg_part.header["Content-Transfer-Encoding"] = 'quoted-printable'
|
|
719
|
+
msg_part.body = [msg_part.body].pack('M')
|
|
720
|
+
else
|
|
721
|
+
msg_part.header["Content-Transfer-Encoding"] = 'base64'
|
|
722
|
+
msg_part.body = [msg_part.body].pack('m')
|
|
723
|
+
end
|
|
724
|
+
msg_part
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
end
|