]> git.cworth.org Git - sup/blob - lib/sup/message.rb
Merge branch 'master' into next
[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.each { |k, v| header[k.downcase] = v }
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         PersonManager.person_for header["from"]
82       else
83         fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
84         PersonManager.person_for 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 = PersonManager.people_for header["to"]
109     @cc = PersonManager.people_for header["cc"]
110     @bcc = PersonManager.people_for 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 = PersonManager.person_for header["reply-to"]
121     @list_address =
122       if header["list-post"]
123         @list_address = PersonManager.person_for 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 ? PersonManager.person_for(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         [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
427
428       ## otherwise, it's body text
429       else
430         body = Message.convert_from m.decode, m.charset if m.body
431         text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
432       end
433     end
434   end
435
436   def self.convert_from body, charset
437     begin
438       raise MessageFormatError, "RubyMail decode returned a null body" unless body
439       return body unless charset
440       Iconv.easy_decode($encoding, charset, body)
441     rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
442       Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
443       File.open(File.join(BASE_DIR,"unable-to-decode.txt"), "w") { |f| f.write body }
444       body
445     end
446   end
447
448   ## parse the lines of text into chunk objects.  the heuristics here
449   ## need tweaking in some nice manner. TODO: move these heuristics
450   ## into the classes themselves.
451   def text_to_chunks lines, encrypted
452     state = :text # one of :text, :quote, or :sig
453     chunks = []
454     chunk_lines = []
455
456     lines.each_with_index do |line, i|
457       nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
458
459       case state
460       when :text
461         newstate = nil
462
463         if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
464           newstate = :quote
465         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
466           newstate = :sig
467         elsif line =~ BLOCK_QUOTE_PATTERN
468           newstate = :block_quote
469         end
470
471         if newstate
472           chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
473           chunk_lines = [line]
474           state = newstate
475         else
476           chunk_lines << line
477         end
478
479       when :quote
480         newstate = nil
481
482         if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
483           chunk_lines << line
484         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
485           newstate = :sig
486         else
487           newstate = :text
488         end
489
490         if newstate
491           if chunk_lines.empty?
492             # nothing
493           else
494             chunks << Chunk::Quote.new(chunk_lines)
495           end
496           chunk_lines = [line]
497           state = newstate
498         end
499
500       when :block_quote, :sig
501         chunk_lines << line
502       end
503
504       if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
505         @snippet ||= ""
506         @snippet += " " unless @snippet.empty?
507         @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
508         @snippet = @snippet[0 ... SNIPPET_LEN].chomp
509         @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
510         @snippet_contains_encrypted_content = true if encrypted
511       end
512     end
513
514     ## final object
515     case state
516     when :quote, :block_quote
517       chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
518     when :text
519       chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
520     when :sig
521       chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?
522     end
523     chunks
524   end
525 end
526
527 end