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