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