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