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