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.
Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.travis.yml +12 -0
  4. data/CONTRIBUTORS +84 -0
  5. data/Gemfile +3 -0
  6. data/HACKING +42 -0
  7. data/History.txt +361 -0
  8. data/LICENSE +280 -0
  9. data/README.md +70 -0
  10. data/Rakefile +12 -0
  11. data/ReleaseNotes +231 -0
  12. data/bin/sup +434 -0
  13. data/bin/sup-add +118 -0
  14. data/bin/sup-config +243 -0
  15. data/bin/sup-dump +43 -0
  16. data/bin/sup-import-dump +101 -0
  17. data/bin/sup-psych-ify-config-files +21 -0
  18. data/bin/sup-recover-sources +87 -0
  19. data/bin/sup-sync +210 -0
  20. data/bin/sup-sync-back-maildir +127 -0
  21. data/bin/sup-tweak-labels +140 -0
  22. data/contrib/colorpicker.rb +100 -0
  23. data/contrib/completion/_sup.zsh +114 -0
  24. data/devel/console.sh +3 -0
  25. data/devel/count-loc.sh +3 -0
  26. data/devel/load-index.rb +9 -0
  27. data/devel/profile.rb +12 -0
  28. data/devel/start-console.rb +5 -0
  29. data/doc/FAQ.txt +119 -0
  30. data/doc/Hooks.txt +79 -0
  31. data/doc/Philosophy.txt +69 -0
  32. data/lib/sup.rb +467 -0
  33. data/lib/sup/account.rb +90 -0
  34. data/lib/sup/buffer.rb +768 -0
  35. data/lib/sup/colormap.rb +239 -0
  36. data/lib/sup/contact.rb +67 -0
  37. data/lib/sup/crypto.rb +461 -0
  38. data/lib/sup/draft.rb +119 -0
  39. data/lib/sup/hook.rb +159 -0
  40. data/lib/sup/horizontal_selector.rb +59 -0
  41. data/lib/sup/idle.rb +42 -0
  42. data/lib/sup/index.rb +882 -0
  43. data/lib/sup/interactive_lock.rb +89 -0
  44. data/lib/sup/keymap.rb +140 -0
  45. data/lib/sup/label.rb +87 -0
  46. data/lib/sup/logger.rb +77 -0
  47. data/lib/sup/logger/singleton.rb +10 -0
  48. data/lib/sup/maildir.rb +257 -0
  49. data/lib/sup/mbox.rb +187 -0
  50. data/lib/sup/message.rb +803 -0
  51. data/lib/sup/message_chunks.rb +328 -0
  52. data/lib/sup/mode.rb +140 -0
  53. data/lib/sup/modes/buffer_list_mode.rb +50 -0
  54. data/lib/sup/modes/completion_mode.rb +55 -0
  55. data/lib/sup/modes/compose_mode.rb +38 -0
  56. data/lib/sup/modes/console_mode.rb +125 -0
  57. data/lib/sup/modes/contact_list_mode.rb +148 -0
  58. data/lib/sup/modes/edit_message_async_mode.rb +110 -0
  59. data/lib/sup/modes/edit_message_mode.rb +728 -0
  60. data/lib/sup/modes/file_browser_mode.rb +109 -0
  61. data/lib/sup/modes/forward_mode.rb +82 -0
  62. data/lib/sup/modes/help_mode.rb +19 -0
  63. data/lib/sup/modes/inbox_mode.rb +85 -0
  64. data/lib/sup/modes/label_list_mode.rb +138 -0
  65. data/lib/sup/modes/label_search_results_mode.rb +38 -0
  66. data/lib/sup/modes/line_cursor_mode.rb +203 -0
  67. data/lib/sup/modes/log_mode.rb +57 -0
  68. data/lib/sup/modes/person_search_results_mode.rb +12 -0
  69. data/lib/sup/modes/poll_mode.rb +19 -0
  70. data/lib/sup/modes/reply_mode.rb +228 -0
  71. data/lib/sup/modes/resume_mode.rb +52 -0
  72. data/lib/sup/modes/scroll_mode.rb +252 -0
  73. data/lib/sup/modes/search_list_mode.rb +204 -0
  74. data/lib/sup/modes/search_results_mode.rb +59 -0
  75. data/lib/sup/modes/text_mode.rb +76 -0
  76. data/lib/sup/modes/thread_index_mode.rb +1033 -0
  77. data/lib/sup/modes/thread_view_mode.rb +941 -0
  78. data/lib/sup/person.rb +134 -0
  79. data/lib/sup/poll.rb +272 -0
  80. data/lib/sup/rfc2047.rb +56 -0
  81. data/lib/sup/search.rb +110 -0
  82. data/lib/sup/sent.rb +58 -0
  83. data/lib/sup/service/label_service.rb +45 -0
  84. data/lib/sup/source.rb +244 -0
  85. data/lib/sup/tagger.rb +50 -0
  86. data/lib/sup/textfield.rb +253 -0
  87. data/lib/sup/thread.rb +452 -0
  88. data/lib/sup/time.rb +93 -0
  89. data/lib/sup/undo.rb +38 -0
  90. data/lib/sup/update.rb +30 -0
  91. data/lib/sup/util.rb +747 -0
  92. data/lib/sup/util/ncurses.rb +274 -0
  93. data/lib/sup/util/path.rb +9 -0
  94. data/lib/sup/util/query.rb +17 -0
  95. data/lib/sup/util/uri.rb +15 -0
  96. data/lib/sup/version.rb +3 -0
  97. data/sup.gemspec +53 -0
  98. data/test/dummy_source.rb +61 -0
  99. data/test/gnupg_test_home/gpg.conf +1 -0
  100. data/test/gnupg_test_home/pubring.gpg +0 -0
  101. data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
  102. data/test/gnupg_test_home/receiver_secring.gpg +0 -0
  103. data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
  104. data/test/gnupg_test_home/secring.gpg +0 -0
  105. data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
  106. data/test/gnupg_test_home/trustdb.gpg +0 -0
  107. data/test/integration/test_label_service.rb +18 -0
  108. data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
  109. data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
  110. data/test/messages/missing-line.eml +9 -0
  111. data/test/test_crypto.rb +109 -0
  112. data/test/test_header_parsing.rb +168 -0
  113. data/test/test_helper.rb +7 -0
  114. data/test/test_message.rb +532 -0
  115. data/test/test_messages_dir.rb +147 -0
  116. data/test/test_yaml_migration.rb +85 -0
  117. data/test/test_yaml_regressions.rb +17 -0
  118. data/test/unit/service/test_label_service.rb +19 -0
  119. data/test/unit/test_horizontal_selector.rb +40 -0
  120. data/test/unit/util/test_query.rb +46 -0
  121. data/test/unit/util/test_string.rb +57 -0
  122. data/test/unit/util/test_uri.rb +19 -0
  123. 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