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