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