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