]> git.cworth.org Git - sup/blob - lib/sup/message.rb
improved source error handling
[sup] / lib / sup / message.rb
1 require 'tempfile'
2 require 'time'
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 ## TODO: integrate with user's addressbook to render names
17 ## appropriately.
18 class Message
19   SNIPPET_LEN = 80
20   WRAP_LEN = 80 # wrap at this width
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   class Attachment
31     attr_reader :content_type, :desc, :filename
32     def initialize content_type, desc, part
33       @content_type = content_type
34       @desc = desc
35       @part = part
36       @file = nil
37       desc =~ /filename="(.*?)"/ && @filename = $1
38     end
39
40     def view!
41       unless @file
42         @file = Tempfile.new "redwood.attachment"
43         @file.print self
44         @file.close
45       end
46
47       ## TODO: handle unknown mime-types
48       system "/usr/bin/run-mailcap --action=view #{@content_type}:#{@file.path}"
49       $? == 0
50     end
51
52     def to_s; @part.decode; end
53   end
54
55   class Text
56     attr_reader :lines
57     def initialize lines
58       ## do some wrapping
59       @lines = lines.map { |l| l.chomp.wrap WRAP_LEN }.flatten
60     end
61   end
62
63   class Quote
64     attr_reader :lines
65     def initialize lines
66       @lines = lines
67     end
68   end
69
70   class Signature
71     attr_reader :lines
72     def initialize lines
73       @lines = lines
74     end
75   end
76
77   QUOTE_PATTERN = /^\s{0,4}[>|\}]/
78   BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
79   QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
80   SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
81   MAX_SIG_DISTANCE = 15 # lines from the end
82   DEFAULT_SUBJECT = "(missing subject)"
83   DEFAULT_SENDER = "(missing sender)"
84
85   attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
86               :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
87               :source_info, :chunks
88
89   bool_reader :dirty, :source_marked_read
90
91   ## if you specify a :header, will use values from that. otherwise, will try and
92   ## load the header from the source.
93   def initialize opts
94     @source = opts[:source] or raise ArgumentError, "source can't be nil"
95     @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
96     @snippet = opts[:snippet] || ""
97     @have_snippet = !opts[:snippet].nil?
98     @labels = opts[:labels] || []
99     @dirty = false
100     @chunks = nil
101
102     read_header(opts[:header] || @source.load_header(@source_info))
103   end
104
105   def read_header header
106     header.each { |k, v| header[k.downcase] = v }
107
108     %w(message-id date).each do |f|
109       raise MessageFormatError, "no #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header.include? f
110       raise MessageFormatError, "nil #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header[f]
111     end
112
113     begin
114       date = header["date"]
115       @date = Time === date ? date : Time.parse(header["date"])
116     rescue ArgumentError => e
117       raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
118     end
119
120     @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
121     @from = Person.for header["from"]
122     @to = Person.for_several header["to"]
123     @cc = Person.for_several header["cc"]
124     @bcc = Person.for_several header["bcc"]
125     @id = header["message-id"]
126     @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
127     @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
128     @replyto = Person.for header["reply-to"]
129     @list_address =
130       if header["list-post"]
131         @list_address = Person.for header["list-post"].gsub(/^<mailto:|>$/, "")
132       else
133         nil
134       end
135
136     @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
137     @source_marked_read = header["status"] == "RO"
138   end
139   private :read_header
140
141   def snippet; @snippet || chunks && @snippet; end
142   def is_list_message?; !@list_address.nil?; end
143   def is_draft?; DraftLoader === @source; end
144   def draft_filename
145     raise "not a draft" unless is_draft?
146     @source.fn_for_offset @source_info
147   end
148
149   def save index
150     index.sync_message self if @dirty
151     @dirty = false
152   end
153
154   def has_label? t; @labels.member? t; end
155   def add_label t
156     return if @labels.member? t
157     @labels.push t
158     @dirty = true
159   end
160   def remove_label t
161     return unless @labels.member? t
162     @labels.delete t
163     @dirty = true
164   end
165
166   def recipients
167     @to + @cc + @bcc
168   end
169
170   def labels= l
171     @labels = l
172     @dirty = true
173   end
174
175   ## this is called when the message body needs to actually be loaded.
176   def load_from_source!
177     @chunks ||=
178       if @source.has_errors?
179         [Text.new(error_message(@source.error.message.split("\n")))]
180       else
181         begin
182           ## we need to re-read the header because it contains information
183           ## that we don't store in the index. actually i think it's just
184           ## the mailing list address (if any), so this is kinda overkill.
185           ## i could just store that in the index, but i think there might
186           ## be other things like that in the future, and i'd rather not
187           ## bloat the index.
188           ## actually, it's also the differentiation between to/cc/bcc,
189           ## so i will keep this.
190           read_header @source.load_header(@source_info)
191           message_to_chunks @source.load_message(@source_info)
192         rescue SourceError, SocketError, MessageFormatError => e
193           [Text.new(error_message(e.message))]
194         end
195       end
196   end
197
198   def error_message msg
199     <<EOS
200 #@snippet...
201
202 ***********************************************************************
203  An error occurred while loading this message. It is possible that
204  the source has changed, or (in the case of remote sources) is down.
205  The message source and offset are: #@source##@source_info
206 ***********************************************************************
207
208 The error message was:
209   #{msg}
210 EOS
211   end
212
213   def raw_header
214     begin
215       @source.raw_header @source_info
216     rescue SourceError => e
217       error_message e.message
218     end
219   end
220
221   def raw_full_message
222     begin
223       @source.raw_full_message @source_info
224     rescue SourceError => e
225       error_message(e.message)
226     end
227   end
228
229   def content
230     load_from_source!
231     [
232       from && "#{from.name} #{from.email}",
233       to.map { |p| "#{p.name} #{p.email}" },
234       cc.map { |p| "#{p.name} #{p.email}" },
235       bcc.map { |p| "#{p.name} #{p.email}" },
236       chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
237       Message.normalize_subj(subj),
238     ].flatten.compact.join " "
239   end
240
241   def basic_body_lines
242     chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
243   end
244
245   def basic_header_lines
246     ["From: #{@from.full_address}"] +
247       (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
248       (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
249       (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
250       ["Date: #{@date.rfc822}",
251        "Subject: #{@subj}"]
252   end
253
254 private
255
256   ## everything RubyMail-specific goes here.
257   def message_to_chunks m
258     ret = [] <<
259       case m.header.content_type
260       when "text/plain", nil
261         m.body && body = m.decode or raise MessageFormatError, "For some bizarre reason, RubyMail was unable to parse this message."
262         text_to_chunks body.normalize_whitespace.split("\n")
263       when /^multipart\//
264         nil
265       else
266         disp = m.header["Content-Disposition"] || ""
267         Attachment.new m.header.content_type, disp.gsub(/[\s\n]+/, " "), m
268       end
269     
270     m.each_part { |p| ret << message_to_chunks(p) } if m.multipart?
271     ret.compact.flatten
272   end
273
274   ## parse the lines of text into chunk objects.  the heuristics here
275   ## need tweaking in some nice manner. TODO: move these heuristics
276   ## into the classes themselves.
277   def text_to_chunks lines
278     state = :text # one of :text, :quote, or :sig
279     chunks = []
280     chunk_lines = []
281
282     lines.each_with_index do |line, i|
283       nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
284
285       case state
286       when :text
287         newstate = nil
288
289         if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
290           newstate = :quote
291         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
292           newstate = :sig
293         elsif line =~ BLOCK_QUOTE_PATTERN
294           newstate = :block_quote
295         end
296
297         if newstate
298           chunks << Text.new(chunk_lines) unless chunk_lines.empty?
299           chunk_lines = [line]
300           state = newstate
301         else
302           chunk_lines << line
303         end
304
305       when :quote
306         newstate = nil
307
308         if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
309           chunk_lines << line
310         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
311           newstate = :sig
312         else
313           newstate = :text
314         end
315
316         if newstate
317           if chunk_lines.empty?
318             # nothing
319           elsif chunk_lines.size == 1
320             chunks << Text.new(chunk_lines) # forget about one-line quotes
321           else
322             chunks << Quote.new(chunk_lines)
323           end
324           chunk_lines = [line]
325           state = newstate
326         end
327
328       when :block_quote
329         chunk_lines << line
330
331       when :sig
332         chunk_lines << line
333       end
334  
335       if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
336         @snippet += " " unless @snippet.empty?
337         @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
338         @snippet = @snippet[0 ... SNIPPET_LEN].chomp
339       end
340     end
341
342     ## final object
343     case state
344     when :quote, :block_quote
345       chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
346     when :text
347       chunks << Text.new(chunk_lines) unless chunk_lines.empty?
348     when :sig
349       chunks << Signature.new(chunk_lines) unless chunk_lines.empty?
350     end
351     chunks
352   end
353 end
354
355 end