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