]> git.cworth.org Git - sup/blob - lib/sup/message.rb
many many changes. this is what happens when i have 5 hours on an airplane
[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"] &&
356             m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
357           $1
358         elsif m.header["Content-Type"] &&
359             m.header["Content-Type"] =~ /name=(.*?)(;|$)/
360           $1
361
362         ## haven't found one, but it's a non-text message. fake
363         ## it.
364         elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
365           "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
366         end
367
368       ## if there's a filename, we'll treat it as an attachment.
369       if filename
370         [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
371
372       ## otherwise, it's body text
373       else
374         body = Message.convert_from m.decode, m.charset
375         text_to_chunks body.normalize_whitespace.split("\n")
376       end
377     end
378   end
379
380   def self.convert_from body, charset
381     begin
382       raise MessageFormatError, "RubyMail decode returned a null body" unless body
383       return body unless charset
384       Iconv.iconv($encoding, charset, body).join
385     rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
386       Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
387       File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
388       body
389     end
390   end
391
392   ## parse the lines of text into chunk objects.  the heuristics here
393   ## need tweaking in some nice manner. TODO: move these heuristics
394   ## into the classes themselves.
395   def text_to_chunks lines
396     state = :text # one of :text, :quote, or :sig
397     chunks = []
398     chunk_lines = []
399
400     lines.each_with_index do |line, i|
401       nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
402
403       case state
404       when :text
405         newstate = nil
406
407         if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
408           newstate = :quote
409         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
410           newstate = :sig
411         elsif line =~ BLOCK_QUOTE_PATTERN
412           newstate = :block_quote
413         end
414
415         if newstate
416           chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
417           chunk_lines = [line]
418           state = newstate
419         else
420           chunk_lines << line
421         end
422
423       when :quote
424         newstate = nil
425
426         if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
427           chunk_lines << line
428         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
429           newstate = :sig
430         else
431           newstate = :text
432         end
433
434         if newstate
435           if chunk_lines.empty?
436             # nothing
437           else
438             chunks << Chunk::Quote.new(chunk_lines)
439           end
440           chunk_lines = [line]
441           state = newstate
442         end
443
444       when :block_quote, :sig
445         chunk_lines << line
446       end
447  
448       if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
449         @snippet += " " unless @snippet.empty?
450         @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
451         @snippet = @snippet[0 ... SNIPPET_LEN].chomp
452       end
453     end
454
455     ## final object
456     case state
457     when :quote, :block_quote
458       chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
459     when :text
460       chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
461     when :sig
462       chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?
463     end
464     chunks
465   end
466 end
467
468 end