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