]> git.cworth.org Git - sup/blob - lib/sup/message.rb
cryptosig improvements
[sup] / lib / sup / message.rb
1 require 'tempfile'
2 require 'time'
3 require 'iconv'
4
5 module Redwood
6
7 class MessageFormatError < StandardError; end
8
9 ## a Message is what's threaded.
10 ##
11 ## it is also where the parsing for quotes and signatures is done, but
12 ## that should be moved out to a separate class at some point (because
13 ## i would like, for example, to be able to add in a ruby-talk
14 ## specific module that would detect and link to /ruby-talk:\d+/
15 ## sequences in the text of an email. (how sweet would that be?)
16 class Message
17   SNIPPET_LEN = 80
18   WRAP_LEN = 80 # wrap at this width
19   RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
20
21   HookManager.register "mime-decode", <<EOS
22 Executes when decoding a MIME attachment.
23 Variables:
24    content_type: the content-type of the message
25        filename: the filename of the attachment as saved to disk (generated
26                  on the fly, so don't call more than once)
27   sibling_types: if this attachment is part of a multipart MIME attachment,
28                  an array of content-types for all attachments. Otherwise,
29                  the empty array.
30 Return value:
31   The decoded text of the attachment, or nil if not decoded.
32 EOS
33 #' stupid ruby-mode
34
35   ## some utility methods
36   class << self
37     def normalize_subj s; s.gsub(RE_PATTERN, ""); end
38     def subj_is_reply? s; s =~ RE_PATTERN; end
39     def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
40   end
41
42   class Attachment
43     ## encoded_content is still possible MIME-encoded
44     ##
45     ## raw_content is after decoding but before being turned into
46     ## inlineable text.
47     ##
48     ## lines is array of inlineable text.
49
50     attr_reader :content_type, :filename, :lines, :raw_content
51
52     def initialize content_type, filename, encoded_content, sibling_types
53       @content_type = content_type
54       @filename = filename
55       @raw_content = encoded_content.decode
56
57       @lines = 
58         case @content_type
59         when /^text\/plain\b/
60           Message.convert_from(@raw_content, encoded_content.charset).split("\n")
61         else
62           text = HookManager.run "mime-decode", :content_type => content_type,
63                                  :filename => lambda { write_to_disk },
64                                  :sibling_types => sibling_types
65           text.split("\n") if text
66           
67         end
68     end
69
70     def inlineable?; !@lines.nil? end
71
72     def view!
73       path = write_to_disk
74       system "/usr/bin/run-mailcap --action=view #{@content_type}:#{path} >& /dev/null"
75       $? == 0
76     end
77     
78     ## used when viewing the attachment as text
79     def to_s
80       @lines || @raw_content
81     end
82
83   private
84
85     def write_to_disk
86       file = Tempfile.new "redwood.attachment"
87       file.print @raw_content
88       file.close
89       file.path
90     end
91   end
92
93   class Text
94     attr_reader :lines
95     def initialize lines
96       ## do some wrapping
97       @lines = lines.map { |l| l.chomp.wrap WRAP_LEN }.flatten
98     end
99   end
100
101   class Quote
102     attr_reader :lines
103     def initialize lines
104       @lines = lines
105     end
106   end
107
108   class Signature
109     attr_reader :lines
110     def initialize lines
111       @lines = lines
112     end
113   end
114
115   class CryptoSignature
116     attr_reader :lines, :description
117
118     def initialize payload, signature
119       @payload = payload
120       @signature = signature
121       @status = nil
122       @description = nil
123       @lines = []
124     end
125
126     def status
127       verify
128       @status
129     end
130
131     def description
132       verify
133       @description
134     end
135
136 private
137
138     def verify
139       @status, @description, @lines = CryptoManager.verify(@payload, @signature) unless @status
140     end
141   end
142
143   QUOTE_PATTERN = /^\s{0,4}[>|\}]/
144   BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
145   QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
146   SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
147
148   MAX_SIG_DISTANCE = 15 # lines from the end
149   DEFAULT_SUBJECT = ""
150   DEFAULT_SENDER = "(missing sender)"
151
152   attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
153               :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
154               :source_info, :chunks
155
156   bool_reader :dirty, :source_marked_read
157
158   ## if you specify a :header, will use values from that. otherwise,
159   ## will try and load the header from the source.
160   def initialize opts
161     @source = opts[:source] or raise ArgumentError, "source can't be nil"
162     @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
163     @snippet = opts[:snippet] || ""
164     @have_snippet = !opts[:snippet].nil?
165     @labels = [] + (opts[:labels] || [])
166     @dirty = false
167     @chunks = nil
168
169     parse_header(opts[:header] || @source.load_header(@source_info))
170   end
171
172   def parse_header header
173     header.each { |k, v| header[k.downcase] = v }
174
175     @from = PersonManager.person_for header["from"]
176
177     @id = header["message-id"]
178     unless @id
179       @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
180       Redwood::log "faking message-id for message from #@from: #@id"
181     end
182
183     date = header["date"]
184     @date =
185       case date
186       when Time
187         date
188       when String
189         begin
190           Time.parse date
191         rescue ArgumentError => e
192           raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
193         end
194       else
195         Redwood::log "faking date header for #{@id}"
196         Time.now
197       end
198
199     @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
200     @to = PersonManager.people_for header["to"]
201     @cc = PersonManager.people_for header["cc"]
202     @bcc = PersonManager.people_for header["bcc"]
203     @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
204     @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
205     @replyto = PersonManager.person_for header["reply-to"]
206     @list_address =
207       if header["list-post"]
208         @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
209       else
210         nil
211       end
212
213     @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
214     @source_marked_read = header["status"] == "RO"
215   end
216   private :parse_header
217
218   def snippet; @snippet || chunks && @snippet; end
219   def is_list_message?; !@list_address.nil?; end
220   def is_draft?; @source.is_a? DraftLoader; end
221   def draft_filename
222     raise "not a draft" unless is_draft?
223     @source.fn_for_offset @source_info
224   end
225
226   def save index
227     index.sync_message self if @dirty
228     @dirty = false
229   end
230
231   def has_label? t; @labels.member? t; end
232   def add_label t
233     return if @labels.member? t
234     @labels.push t
235     @dirty = true
236   end
237   def remove_label t
238     return unless @labels.member? t
239     @labels.delete t
240     @dirty = true
241   end
242
243   def recipients
244     @to + @cc + @bcc
245   end
246
247   def labels= l
248     @labels = l
249     @dirty = true
250   end
251
252   ## this is called when the message body needs to actually be loaded.
253   def load_from_source!
254     @chunks ||=
255       if @source.has_errors?
256         [Text.new(error_message(@source.error.message.split("\n")))]
257       else
258         begin
259           ## we need to re-read the header because it contains information
260           ## that we don't store in the index. actually i think it's just
261           ## the mailing list address (if any), so this is kinda overkill.
262           ## i could just store that in the index, but i think there might
263           ## be other things like that in the future, and i'd rather not
264           ## bloat the index.
265           ## actually, it's also the differentiation between to/cc/bcc,
266           ## so i will keep this.
267           parse_header @source.load_header(@source_info)
268           message_to_chunks @source.load_message(@source_info)
269         rescue SourceError, SocketError, MessageFormatError => e
270           Redwood::log "problem getting messages from #{@source}: #{e.message}"
271           ## we need force_to_top here otherwise this window will cover
272           ## up the error message one
273           Redwood::report_broken_sources :force_to_top => true
274           [Text.new(error_message(e.message))]
275         end
276       end
277   end
278
279   def error_message msg
280     <<EOS
281 #@snippet...
282
283 ***********************************************************************
284  An error occurred while loading this message. It is possible that
285  the source has changed, or (in the case of remote sources) is down.
286  You can check the log for errors, though hopefully an error window
287  should have popped up at some point.
288
289  The message location was:
290  #@source##@source_info
291 ***********************************************************************
292
293 The error message was:
294   #{msg}
295 EOS
296   end
297
298   def with_source_errors_handled
299     begin
300       yield
301     rescue SourceError => e
302       Redwood::log "problem getting messages from #{@source}: #{e.message}"
303       error_message e.message
304     end
305   end
306
307   def raw_header
308     with_source_errors_handled { @source.raw_header @source_info }
309   end
310
311   def raw_full_message
312     with_source_errors_handled { @source.raw_full_message @source_info }
313   end
314
315   ## much faster than raw_full_message
316   def each_raw_full_message_line &b
317     with_source_errors_handled { @source.each_raw_full_message_line(@source_info, &b) }
318   end
319
320   def content
321     load_from_source!
322     [
323       from && "#{from.name} #{from.email}",
324       to.map { |p| "#{p.name} #{p.email}" },
325       cc.map { |p| "#{p.name} #{p.email}" },
326       bcc.map { |p| "#{p.name} #{p.email}" },
327       chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
328       Message.normalize_subj(subj),
329     ].flatten.compact.join " "
330   end
331
332   def basic_body_lines
333     chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
334   end
335
336   def basic_header_lines
337     ["From: #{@from.full_address}"] +
338       (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
339       (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
340       (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
341       ["Date: #{@date.rfc822}",
342        "Subject: #{@subj}"]
343   end
344
345 private
346
347   ## here's where we handle decoding mime attachments. unfortunately
348   ## but unsurprisingly, the world of mime attachments is a bit of a
349   ## mess. as an empiricist, i'm basing the following behavior on
350   ## observed mail rather than on interpretations of rfcs, so probably
351   ## this will have to be tweaked.
352   ##
353   ## the general behavior i want is: ignore content-disposition, at
354   ## least in so far as it suggests something being inline vs being an
355   ## attachment. (because really, that should be the recipient's
356   ## decision to make.) if a mime part is text/plain, OR if the user
357   ## decoding hook converts it, then decode it and display it
358   ## inline. for these decoded attachments, if it has associated
359   ## filename, then make it collapsable and individually saveable;
360   ## otherwise, treat it as regular body text.
361   ##
362   ## everything else is just an attachment and is not displayed
363   ## inline.
364   ##
365   ## so, in contrast to mutt, the user is not exposed to the workings
366   ## of the gruesome slaughterhouse and sausage factory that is a
367   ## mime-encoded message, but need only see the delicious end
368   ## product.
369
370   def multipart_signed_to_chunks m
371 #    Redwood::log ">> multipart SIGNED: #{m.header['Content-Type']}: #{m.body.size}"
372     if m.body.size != 2
373       Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
374       return
375     end
376
377     payload, signature = m.body
378     if signature.multipart?
379       Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
380       return
381     end
382
383     if payload.header.content_type == "application/pgp-signature"
384       Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
385       return
386     end
387
388     if signature.header.content_type != "application/pgp-signature"
389       Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
390       return
391     end
392
393     [CryptoSignature.new(payload, signature), message_to_chunks(payload)].flatten
394   end
395         
396   def message_to_chunks m, sibling_types=[]
397     if m.multipart?
398       chunks = multipart_signed_to_chunks(m) if m.header.content_type == "multipart/signed"
399       unless chunks
400         sibling_types = m.body.map { |p| p.header.content_type }
401         chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact
402       end
403       chunks
404     else
405       filename =
406         ## first, paw through the headers looking for a filename
407         if m.header["Content-Disposition"] &&
408             m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
409           $1
410         elsif m.header["Content-Type"] &&
411             m.header["Content-Type"] =~ /name=(.*?)(;|$)/
412           $1
413
414         ## haven't found one, but it's a non-text message. fake
415         ## it.
416         elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
417           "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
418         end
419
420       ## if there's a filename, we'll treat it as an attachment.
421       if filename
422         [Attachment.new(m.header.content_type, filename, m, sibling_types)]
423
424       ## otherwise, it's body text
425       else
426         body = Message.convert_from m.decode, m.charset
427         text_to_chunks body.normalize_whitespace.split("\n")
428       end
429     end
430   end
431
432   def self.convert_from body, charset
433     return body unless charset
434
435     begin
436       Iconv.iconv($encoding, charset, body).join
437     rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
438       Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
439       File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
440       body
441     end
442   end
443
444   ## parse the lines of text into chunk objects.  the heuristics here
445   ## need tweaking in some nice manner. TODO: move these heuristics
446   ## into the classes themselves.
447   def text_to_chunks lines
448     state = :text # one of :text, :quote, or :sig
449     chunks = []
450     chunk_lines = []
451
452     lines.each_with_index do |line, i|
453       nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
454
455       case state
456       when :text
457         newstate = nil
458
459         if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
460           newstate = :quote
461         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
462           newstate = :sig
463         elsif line =~ BLOCK_QUOTE_PATTERN
464           newstate = :block_quote
465         end
466
467         if newstate
468           chunks << Text.new(chunk_lines) unless chunk_lines.empty?
469           chunk_lines = [line]
470           state = newstate
471         else
472           chunk_lines << line
473         end
474
475       when :quote
476         newstate = nil
477
478         if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
479           chunk_lines << line
480         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
481           newstate = :sig
482         else
483           newstate = :text
484         end
485
486         if newstate
487           if chunk_lines.empty?
488             # nothing
489           else
490             chunks << Quote.new(chunk_lines)
491           end
492           chunk_lines = [line]
493           state = newstate
494         end
495
496       when :block_quote, :sig
497         chunk_lines << line
498       end
499  
500       if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
501         @snippet += " " unless @snippet.empty?
502         @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
503         @snippet = @snippet[0 ... SNIPPET_LEN].chomp
504       end
505     end
506
507     ## final object
508     case state
509     when :quote, :block_quote
510       chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
511     when :text
512       chunks << Text.new(chunk_lines) unless chunk_lines.empty?
513     when :sig
514       chunks << Signature.new(chunk_lines) unless chunk_lines.empty?
515     end
516     chunks
517   end
518 end
519
520 end