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