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