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