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