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