]> git.cworth.org Git - sup/blob - lib/sup/message.rb
very preliminary gpg signature verification
[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 valid?; status == :valid end
127
128     def status
129       return @status if @status
130       payload = Tempfile.new "redwood.payload"
131       signature = Tempfile.new "redwood.signature"
132
133       payload.write @payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n")
134       payload.close
135
136       signature.write @signature.decode
137       signature.close
138
139       cmd = "gpg --quiet --batch --no-verbose --verify --logger-fd 1 #{signature.path} #{payload.path}"
140       #Redwood::log "gpg: running: #{cmd}"
141       gpg_output = `#{cmd}`
142       #Redwood::log "got output: #{gpg_output.inspect}"
143       @lines = gpg_output.split(/\n/)
144
145       @description =
146         if gpg_output =~ /^gpg: (.* signature from .*$)/
147           $1
148         else
149           "Unable to determine signature validity"
150         end
151
152       @status = ($? == 0 ? :valid : :invalid)
153     end
154   end
155
156   QUOTE_PATTERN = /^\s{0,4}[>|\}]/
157   BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
158   QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
159   SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
160
161   MAX_SIG_DISTANCE = 15 # lines from the end
162   DEFAULT_SUBJECT = ""
163   DEFAULT_SENDER = "(missing sender)"
164
165   attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
166               :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
167               :source_info, :chunks
168
169   bool_reader :dirty, :source_marked_read
170
171   ## if you specify a :header, will use values from that. otherwise,
172   ## will try and load the header from the source.
173   def initialize opts
174     @source = opts[:source] or raise ArgumentError, "source can't be nil"
175     @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
176     @snippet = opts[:snippet] || ""
177     @have_snippet = !opts[:snippet].nil?
178     @labels = [] + (opts[:labels] || [])
179     @dirty = false
180     @chunks = nil
181
182     parse_header(opts[:header] || @source.load_header(@source_info))
183   end
184
185   def parse_header header
186     header.each { |k, v| header[k.downcase] = v }
187
188     @from = PersonManager.person_for header["from"]
189
190     @id = header["message-id"]
191     unless @id
192       @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
193       Redwood::log "faking message-id for message from #@from: #@id"
194     end
195
196     date = header["date"]
197     @date =
198       case date
199       when Time
200         date
201       when String
202         begin
203           Time.parse date
204         rescue ArgumentError => e
205           raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
206         end
207       else
208         Redwood::log "faking date header for #{@id}"
209         Time.now
210       end
211
212     @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
213     @to = PersonManager.people_for header["to"]
214     @cc = PersonManager.people_for header["cc"]
215     @bcc = PersonManager.people_for header["bcc"]
216     @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
217     @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
218     @replyto = PersonManager.person_for header["reply-to"]
219     @list_address =
220       if header["list-post"]
221         @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
222       else
223         nil
224       end
225
226     @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
227     @source_marked_read = header["status"] == "RO"
228   end
229   private :parse_header
230
231   def snippet; @snippet || chunks && @snippet; end
232   def is_list_message?; !@list_address.nil?; end
233   def is_draft?; @source.is_a? DraftLoader; end
234   def draft_filename
235     raise "not a draft" unless is_draft?
236     @source.fn_for_offset @source_info
237   end
238
239   def save index
240     index.sync_message self if @dirty
241     @dirty = false
242   end
243
244   def has_label? t; @labels.member? t; end
245   def add_label t
246     return if @labels.member? t
247     @labels.push t
248     @dirty = true
249   end
250   def remove_label t
251     return unless @labels.member? t
252     @labels.delete t
253     @dirty = true
254   end
255
256   def recipients
257     @to + @cc + @bcc
258   end
259
260   def labels= l
261     @labels = l
262     @dirty = true
263   end
264
265   ## this is called when the message body needs to actually be loaded.
266   def load_from_source!
267     @chunks ||=
268       if @source.has_errors?
269         [Text.new(error_message(@source.error.message.split("\n")))]
270       else
271         begin
272           ## we need to re-read the header because it contains information
273           ## that we don't store in the index. actually i think it's just
274           ## the mailing list address (if any), so this is kinda overkill.
275           ## i could just store that in the index, but i think there might
276           ## be other things like that in the future, and i'd rather not
277           ## bloat the index.
278           ## actually, it's also the differentiation between to/cc/bcc,
279           ## so i will keep this.
280           parse_header @source.load_header(@source_info)
281           message_to_chunks @source.load_message(@source_info)
282         rescue SourceError, SocketError, MessageFormatError => e
283           Redwood::log "problem getting messages from #{@source}: #{e.message}"
284           ## we need force_to_top here otherwise this window will cover
285           ## up the error message one
286           Redwood::report_broken_sources :force_to_top => true
287           [Text.new(error_message(e.message))]
288         end
289       end
290   end
291
292   def error_message msg
293     <<EOS
294 #@snippet...
295
296 ***********************************************************************
297  An error occurred while loading this message. It is possible that
298  the source has changed, or (in the case of remote sources) is down.
299  You can check the log for errors, though hopefully an error window
300  should have popped up at some point.
301
302  The message location was:
303  #@source##@source_info
304 ***********************************************************************
305
306 The error message was:
307   #{msg}
308 EOS
309   end
310
311   def with_source_errors_handled
312     begin
313       yield
314     rescue SourceError => e
315       Redwood::log "problem getting messages from #{@source}: #{e.message}"
316       error_message e.message
317     end
318   end
319
320   def raw_header
321     with_source_errors_handled { @source.raw_header @source_info }
322   end
323
324   def raw_full_message
325     with_source_errors_handled { @source.raw_full_message @source_info }
326   end
327
328   ## much faster than raw_full_message
329   def each_raw_full_message_line &b
330     with_source_errors_handled { @source.each_raw_full_message_line(@source_info, &b) }
331   end
332
333   def content
334     load_from_source!
335     [
336       from && "#{from.name} #{from.email}",
337       to.map { |p| "#{p.name} #{p.email}" },
338       cc.map { |p| "#{p.name} #{p.email}" },
339       bcc.map { |p| "#{p.name} #{p.email}" },
340       chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
341       Message.normalize_subj(subj),
342     ].flatten.compact.join " "
343   end
344
345   def basic_body_lines
346     chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
347   end
348
349   def basic_header_lines
350     ["From: #{@from.full_address}"] +
351       (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
352       (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
353       (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
354       ["Date: #{@date.rfc822}",
355        "Subject: #{@subj}"]
356   end
357
358 private
359
360   ## here's where we handle decoding mime attachments. unfortunately
361   ## but unsurprisingly, the world of mime attachments is a bit of a
362   ## mess. as an empiricist, i'm basing the following behavior on
363   ## observed mail rather than on interpretations of rfcs, so probably
364   ## this will have to be tweaked.
365   ##
366   ## the general behavior i want is: ignore content-disposition, at
367   ## least in so far as it suggests something being inline vs being an
368   ## attachment. (because really, that should be the recipient's
369   ## decision to make.) if a mime part is text/plain, OR if the user
370   ## decoding hook converts it, then decode it and display it
371   ## inline. for these decoded attachments, if it has associated
372   ## filename, then make it collapsable and individually saveable;
373   ## otherwise, treat it as regular body text.
374   ##
375   ## everything else is just an attachment and is not displayed
376   ## inline.
377   ##
378   ## so, in contrast to mutt, the user is not exposed to the workings
379   ## of the gruesome slaughterhouse and sausage factory that is a
380   ## mime-encoded message, but need only see the delicious end
381   ## product.
382
383   def multipart_signed_to_chunks m
384 #    Redwood::log ">> multipart SIGNED: #{m.header['Content-Type']}: #{m.body.size}"
385     if m.body.size != 2
386       Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
387       return
388     end
389
390     payload, signature = m.body
391     if payload.multipart? || signature.multipart?
392       Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
393       return
394     end
395
396     if payload.header.content_type == "application/pgp-signature"
397       Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
398       return
399     end
400
401     if signature.header.content_type != "application/pgp-signature"
402       Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
403       return
404     end
405
406     [CryptoSignature.new(payload, signature), message_to_chunks(payload)].flatten
407   end
408         
409   def message_to_chunks m, sibling_types=[]
410     if m.multipart?
411       chunks = multipart_signed_to_chunks(m) if m.header.content_type == "multipart/signed"
412       unless chunks
413         sibling_types = m.body.map { |p| p.header.content_type }
414         chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact
415       end
416       chunks
417     else
418       filename =
419         ## first, paw through the headers looking for a filename
420         if m.header["Content-Disposition"] &&
421             m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
422           $1
423         elsif m.header["Content-Type"] &&
424             m.header["Content-Type"] =~ /name=(.*?)(;|$)/
425           $1
426
427         ## haven't found one, but it's a non-text message. fake
428         ## it.
429         elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
430           "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
431         end
432
433       ## if there's a filename, we'll treat it as an attachment.
434       if filename
435         [Attachment.new(m.header.content_type, filename, m, sibling_types)]
436
437       ## otherwise, it's body text
438       else
439         body = Message.convert_from m.decode, m.charset
440         text_to_chunks body.normalize_whitespace.split("\n")
441       end
442     end
443   end
444
445   def self.convert_from body, charset
446     return body unless charset
447
448     begin
449       Iconv.iconv($encoding, charset, body).join
450     rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
451       Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
452       File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
453       body
454     end
455   end
456
457   ## parse the lines of text into chunk objects.  the heuristics here
458   ## need tweaking in some nice manner. TODO: move these heuristics
459   ## into the classes themselves.
460   def text_to_chunks lines
461     state = :text # one of :text, :quote, or :sig
462     chunks = []
463     chunk_lines = []
464
465     lines.each_with_index do |line, i|
466       nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
467
468       case state
469       when :text
470         newstate = nil
471
472         if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
473           newstate = :quote
474         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
475           newstate = :sig
476         elsif line =~ BLOCK_QUOTE_PATTERN
477           newstate = :block_quote
478         end
479
480         if newstate
481           chunks << Text.new(chunk_lines) unless chunk_lines.empty?
482           chunk_lines = [line]
483           state = newstate
484         else
485           chunk_lines << line
486         end
487
488       when :quote
489         newstate = nil
490
491         if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
492           chunk_lines << line
493         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
494           newstate = :sig
495         else
496           newstate = :text
497         end
498
499         if newstate
500           if chunk_lines.empty?
501             # nothing
502           else
503             chunks << Quote.new(chunk_lines)
504           end
505           chunk_lines = [line]
506           state = newstate
507         end
508
509       when :block_quote, :sig
510         chunk_lines << line
511       end
512  
513       if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
514         @snippet += " " unless @snippet.empty?
515         @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
516         @snippet = @snippet[0 ... SNIPPET_LEN].chomp
517       end
518     end
519
520     ## final object
521     case state
522     when :quote, :block_quote
523       chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
524     when :text
525       chunks << Text.new(chunk_lines) unless chunk_lines.empty?
526     when :sig
527       chunks << Signature.new(chunk_lines) unless chunk_lines.empty?
528     end
529     chunks
530   end
531 end
532
533 end