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