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