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