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