Encode multipart messages for crypt operations
[sup:mainline.git] / lib / sup / modes / edit-message-mode.rb
1 require 'tempfile'
2 require 'socket' # just for gethostname!
3 require 'pathname'
4 require 'rmail'
5
6 module Redwood
7
8 class SendmailCommandFailed < StandardError; end
9
10 class EditMessageMode < LineCursorMode
11   DECORATION_LINES = 1
12
13   FORCE_HEADERS = %w(From To Cc Bcc Subject)
14   MULTI_HEADERS = %w(To Cc Bcc)
15   NON_EDITABLE_HEADERS = %w(Message-id Date)
16
17   HookManager.register "signature", <<EOS
18 Generates a message signature.
19 Variables:
20       header: an object that supports string-to-string hashtable-style access
21               to the raw headers for the message. E.g., header["From"],
22               header["To"], etc.
23   from_email: the email part of the From: line, or nil if empty
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 "before-edit", <<EOS
30 Modifies message body and headers before editing a new message. Variables
31 should be modified in place.
32 Variables:
33         header: a hash of headers. See 'signature' hook for documentation.
34         body: an array of lines of body text.
35 Return value:
36         none
37 EOS
38
39   HookManager.register "mentions-attachments", <<EOS
40 Detects if given message mentions attachments the way it is probable
41 that there should be files attached to the message.
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         True if attachments are mentioned.
47 EOS
48
49   HookManager.register "crypto-mode", <<EOS
50 Modifies cryptography settings based on header and message content, before
51 editing a new message. This can be used to set, for example, default cryptography
52 settings.
53 Variables:
54     header: a hash of headers. See 'signature' hook for documentation.
55     body: an array of lines of body text.
56     crypto_selector: the UI element that controls the current cryptography setting.
57 Return value:
58      none
59 EOS
60
61   HookManager.register "sendmail", <<EOS
62 Sends the given mail. If this hook doesn't exist, the sendmail command
63 configured for the account is used.
64 The message will be saved after this hook is run, so any modification to it
65 will be recorded.
66 Variables:
67     message: RMail::Message instance of the mail to send
68     account: Account instance matching the From address
69 Return value:
70      True if mail has been sent successfully, false otherwise.
71 EOS
72
73   attr_reader :status
74   attr_accessor :body, :header
75   bool_reader :edited
76
77   register_keymap do |k|
78     k.add :send_message, "Send message", 'y'
79     k.add :edit_message_or_field, "Edit selected field", 'e'
80     k.add :edit_to, "Edit To:", 't'
81     k.add :edit_cc, "Edit Cc:", 'c'
82     k.add :edit_subject, "Edit Subject", 's'
83     k.add :edit_message, "Edit message", :enter
84     k.add :save_as_draft, "Save as draft", 'P'
85     k.add :attach_file, "Attach a file", 'a'
86     k.add :delete_attachment, "Delete an attachment", 'd'
87     k.add :move_cursor_right, "Move selector to the right", :right, 'l'
88     k.add :move_cursor_left, "Move selector to the left", :left, 'h'
89   end
90
91   def initialize opts={}
92     @header = opts.delete(:header) || {} 
93     @header_lines = []
94
95     @body = opts.delete(:body) || []
96     @body += sig_lines if $config[:edit_signature] && !opts.delete(:have_signature)
97
98     if opts[:attachments]
99       @attachments = opts[:attachments].values
100       @attachment_names = opts[:attachments].keys
101     else
102       @attachments = []
103       @attachment_names = []
104     end
105
106     begin
107       hostname = File.open("/etc/mailname", "r").gets.chomp
108     rescue
109         nil
110     end
111     hostname = Socket.gethostname if hostname.nil? or hostname.empty?
112
113     @message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{hostname}>"
114     @edited = false
115     @selectors = []
116     @selector_label_width = 0
117
118     @crypto_selector =
119       if CryptoManager.have_crypto?
120         HorizontalSelector.new "Crypto:", [:none] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.keys, ["None"] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.values
121       end
122     add_selector @crypto_selector if @crypto_selector
123     
124     HookManager.run "before-edit", :header => @header, :body => @body
125     if @crypto_selector
126       HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
127     end
128
129     super opts
130     regen_text
131   end
132
133   def lines; @text.length + (@selectors.empty? ? 0 : (@selectors.length + DECORATION_LINES)) end
134   
135   def [] i
136     if @selectors.empty?
137       @text[i]
138     elsif i < @selectors.length
139       @selectors[i].line @selector_label_width
140     elsif i == @selectors.length
141       ""
142     else
143       @text[i - @selectors.length - DECORATION_LINES]
144     end
145   end
146
147   ## hook for subclasses. i hate this style of programming.
148   def handle_new_text header, body; end
149
150   def edit_message_or_field
151     lines = DECORATION_LINES + @selectors.size
152     if lines > curpos
153       return
154     elsif (curpos - lines) >= @header_lines.length
155       edit_message
156     else
157       edit_field @header_lines[curpos - lines]
158     end
159   end
160
161   def edit_to; edit_field "To" end
162   def edit_cc; edit_field "Cc" end
163   def edit_subject; edit_field "Subject" end
164
165   def edit_message
166     @file = Tempfile.new "sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}"
167     @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first
168     @file.puts
169     @file.puts @body.join("\n")
170     @file.close
171
172     editor = $config[:editor] || ENV['EDITOR'] || "/usr/bin/vi"
173
174     mtime = File.mtime @file.path
175     BufferManager.shell_out "#{editor} #{@file.path}"
176     @edited = true if File.mtime(@file.path) > mtime
177
178     return @edited unless @edited
179
180     header, @body = parse_file @file.path
181     @header = header - NON_EDITABLE_HEADERS
182     handle_new_text @header, @body
183     update
184
185     @edited
186   end
187
188   def killable?
189     !edited? || BufferManager.ask_yes_or_no("Discard message?")
190   end
191
192   def unsaved?; edited? end
193
194   def attach_file
195     fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
196     return unless fn
197     begin
198       Dir[fn].each do |f|
199         @attachments << RMail::Message.make_file_attachment(f)
200         @attachment_names << f
201       end
202       update
203     rescue SystemCallError => e
204       BufferManager.flash "Can't read #{fn}: #{e.message}"
205     end
206   end
207
208   def delete_attachment
209     i = curpos - @attachment_lines_offset - DECORATION_LINES - 1
210     if i >= 0 && i < @attachments.size && BufferManager.ask_yes_or_no("Delete attachment #{@attachment_names[i]}?")
211       @attachments.delete_at i
212       @attachment_names.delete_at i
213       update
214     end
215   end
216
217 protected
218
219   def mime_encode string
220     string = [string].pack('M') # basic quoted-printable
221     string.gsub!(/=\n/,'')      # .. remove trailing newline
222     string.gsub!(/_/,'=96')     # .. encode underscores
223     string.gsub!(/\?/,'=3F')    # .. encode question marks
224     string.gsub!(/ /,'_')       # .. translate space to underscores
225     "=?utf-8?q?#{string}?="
226   end
227
228   def mime_encode_subject string
229     return string if string.ascii_only?
230     mime_encode string
231   end
232
233   RE_ADDRESS = /(.+)( <.*@.*>)/
234
235   # Encode "bælammet mitt <user@example.com>" into
236   # "=?utf-8?q?b=C3=A6lammet_mitt?= <user@example.com>
237   def mime_encode_address string
238     return string if string.ascii_only?
239     string.sub(RE_ADDRESS) { |match| mime_encode($1) + $2 }
240   end
241
242   def move_cursor_left
243     if curpos < @selectors.length
244       @selectors[curpos].roll_left
245       buffer.mark_dirty
246     else
247       col_left
248     end
249   end
250
251   def move_cursor_right
252     if curpos < @selectors.length
253       @selectors[curpos].roll_right
254       buffer.mark_dirty
255     else
256       col_right
257     end
258   end
259
260   def add_selector s
261     @selectors << s
262     @selector_label_width = [@selector_label_width, s.label.length].max
263   end
264
265   def update
266     regen_text
267     buffer.mark_dirty if buffer
268   end
269
270   def regen_text
271     header, @header_lines = format_headers(@header - NON_EDITABLE_HEADERS) + [""]
272     @text = header + [""] + @body
273     @text += sig_lines unless $config[:edit_signature]
274     
275     @attachment_lines_offset = 0
276
277     unless @attachments.empty?
278       @text += [""]
279       @attachment_lines_offset = @text.length
280       @text += (0 ... @attachments.size).map { |i| [[:attachment_color, "+ Attachment: #{@attachment_names[i]} (#{@attachments[i].body.size.to_human_size})"]] }
281     end
282   end
283
284   def parse_file fn
285     File.open(fn) do |f|
286       header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
287       body = f.readlines.map { |l| l.chomp }
288
289       header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
290       header.each { |k, v| header[k] = parse_header k, v }
291
292       [header, body]
293     end
294   end
295
296   def parse_header k, v
297     if MULTI_HEADERS.include?(k)
298       v.split_on_commas.map do |name|
299         (p = ContactManager.contact_for(name)) && p.full_address || name
300       end
301     else
302       v
303     end
304   end
305
306   def format_headers header
307     header_lines = []
308     headers = (FORCE_HEADERS + (header.keys - FORCE_HEADERS)).map do |h|
309       lines = make_lines "#{h}:", header[h]
310       lines.length.times { header_lines << h }
311       lines
312     end.flatten.compact
313     [headers, header_lines]
314   end
315
316   def make_lines header, things
317     case things
318     when nil, []
319       [header + " "]
320     when String
321       [header + " " + things]
322     else
323       if things.empty?
324         [header]
325       else
326         things.map_with_index do |name, i|
327           raise "an array: #{name.inspect} (things #{things.inspect})" if Array === name
328           if i == 0
329             header + " " + name
330           else
331             (" " * (header.display_length + 1)) + name
332           end + (i == things.length - 1 ? "" : ",")
333         end
334       end
335     end
336   end
337
338   def send_message
339     return false if !edited? && !BufferManager.ask_yes_or_no("Message unedited. Really send?")
340     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
341     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
342
343     from_email = 
344       if @header["From"] =~ /<?(\S+@(\S+?))>?$/
345         $1
346       else
347         AccountManager.default_account.email
348       end
349
350     acct = AccountManager.account_for(from_email) || AccountManager.default_account
351     BufferManager.flash "Sending..."
352
353     begin
354       date = Time.now
355       m = build_message date
356
357       if HookManager.enabled? "sendmail"
358     if not HookManager.run "sendmail", :message => m, :account => acct
359           warn "Sendmail hook was not successful"
360           return false
361     end
362       else
363         IO.popen(acct.sendmail, "w") { |p| p.puts m }
364         raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0
365       end
366
367       SentManager.write_sent_message(date, from_email) { |f| f.puts sanitize_body(m.to_s) }
368       BufferManager.kill_buffer buffer
369       BufferManager.flash "Message sent!"
370       true
371     rescue SystemCallError, SendmailCommandFailed, CryptoManager::Error => e
372       warn "Problem sending mail: #{e.message}"
373       BufferManager.flash "Problem sending mail: #{e.message}"
374       false
375     end
376   end
377
378   def save_as_draft
379     DraftManager.write_draft { |f| write_message f, false }
380     BufferManager.kill_buffer buffer
381     BufferManager.flash "Saved for later editing."
382   end
383
384   def build_message date
385     m = RMail::Message.new
386     m.header["Content-Type"] = "text/plain; charset=#{$encoding}"
387     m.body = @body.join("\n")
388     m.body += sig_lines.join("\n") unless $config[:edit_signature]
389     ## body must end in a newline or GPG signatures will be WRONG!
390     m.body += "\n" unless m.body =~ /\n\Z/
391
392     ## there are attachments, so wrap body in an attachment of its own
393     unless @attachments.empty?
394       body_m = m
395       body_m.header["Content-Disposition"] = "inline"
396       m = RMail::Message.new
397       
398       m.add_part body_m
399       @attachments.each { |a| m.add_part a }
400     end
401
402     ## do whatever crypto transformation is necessary
403     if @crypto_selector && @crypto_selector.val != :none
404       from_email = Person.from_address(@header["From"]).email
405       to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
406       if m.multipart?
407         m.each_part {|p| p = transfer_encode p}
408       else
409         m = transfer_encode m
410       end
411
412       m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
413     end
414
415     ## finally, set the top-level headers
416     @header.each do |k, v|
417       next if v.nil? || v.empty?
418       m.header[k] = 
419         case v
420         when String
421           k.match(/subject/i) ? mime_encode_subject(v) : mime_encode_address(v)
422         when Array
423           v.map { |v| mime_encode_address v }.join ", "
424         end
425     end
426
427     m.header["Date"] = date.rfc2822
428     m.header["Message-Id"] = @message_id
429     m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
430     m.header["Content-Transfer-Encoding"] ||= '8bit'
431     m
432   end
433
434   ## TODO: remove this. redundant with write_full_message_to.
435   ##
436   ## this is going to change soon: draft messages (currently written
437   ## with full=false) will be output as yaml.
438   def write_message f, full=true, date=Time.now
439     raise ArgumentError, "no pre-defined date: header allowed" if @header["Date"]
440     f.puts format_headers(@header).first
441     f.puts <<EOS
442 Date: #{date.rfc2822}
443 Message-Id: #{@message_id}
444 EOS
445     if full
446       f.puts <<EOS
447 Mime-Version: 1.0
448 Content-Type: text/plain; charset=us-ascii
449 Content-Disposition: inline
450 User-Agent: Redwood/#{Redwood::VERSION}
451 EOS
452     end
453
454     f.puts
455     f.puts sanitize_body(@body.join("\n"))
456     f.puts sig_lines if full unless $config[:edit_signature]
457   end  
458
459 protected
460
461   def edit_field field
462     case field
463     when "Subject"
464       text = BufferManager.ask :subject, "Subject: ", @header[field]
465        if text
466          @header[field] = parse_header field, text
467          update
468        end
469     else
470       default = case field
471         when *MULTI_HEADERS
472           @header[field] ||= []
473           @header[field].join(", ")
474         else
475           @header[field]
476         end
477
478       contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
479       if contacts
480         text = contacts.map { |s| s.full_address }.join(", ")
481         @header[field] = parse_header field, text
482         update
483       end
484     end
485   end
486
487 private
488
489   def sanitize_body body
490     body.gsub(/^From /, ">From ")
491   end
492
493   def mentions_attachments?
494     if HookManager.enabled? "mentions-attachments"
495       HookManager.run "mentions-attachments", :header => @header, :body => @body
496     else
497       @body.any? {  |l| l =~ /^[^>]/ && l =~ /\battach(ment|ed|ing|)\b/i }
498     end
499   end
500
501   def top_posting?
502     @body.join("\n") =~ /(\S+)\s*Excerpts from.*\n(>.*\n)+\s*\Z/
503   end
504
505   def sig_lines
506     p = Person.from_address(@header["From"])
507     from_email = p && p.email
508
509     ## first run the hook
510     hook_sig = HookManager.run "signature", :header => @header, :from_email => from_email
511
512     return [] if hook_sig == :none
513     return ["", "-- "] + hook_sig.split("\n") if hook_sig
514
515     ## no hook, do default signature generation based on config.yaml
516     return [] unless from_email
517     sigfn = (AccountManager.account_for(from_email) || 
518              AccountManager.default_account).signature
519
520     if sigfn && File.exists?(sigfn)
521       ["", "--"] + File.readlines(sigfn).map { |l| l.chomp }
522     else
523       []
524     end
525   end
526
527   def transfer_encode msg_part
528     ## return the message unchanged if it's already encoded
529     if (msg_part.header["Content-Transfer-Encoding"] == "base64" ||
530         msg_part.header["Content-Transfer-Encoding"] == "quoted-printable")
531       return msg_part
532     end
533
534     ## encode to quoted-printable for all text/* MIME types,
535     ## use base64 otherwise
536     if msg_part.header["Content-Type"] =~ /text\/.*/
537       msg_part.header["Content-Transfer-Encoding"] = 'quoted-printable'
538       msg_part.body = [msg_part.body].pack('M')
539     else
540       msg_part.header["Content-Transfer-Encoding"] = 'base64'
541       msg_part.body = [msg_part.body].pack('m')
542     end
543     msg_part
544   end
545 end
546
547 end