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