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