]> git.cworth.org Git - sup/blob - lib/sup/message.rb
fencepost error in end_offset
[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.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, :status
86
87   bool_reader :dirty
88
89   ## if index_entry is specified, will fill in values from that,
90   def initialize opts
91     if opts[:source]
92       @source = opts[:source]
93       @source_info = opts[:source_info] or raise ArgumentError, ":source but no :source_info"
94       @body = nil
95     else
96       @source = @source_info = nil
97       @body = opts[:body] or raise ArgumentError, "one of :body or :source must be specified"
98     end
99     @snippet = opts[:snippet] || ""
100     @labels = opts[:labels] || []
101     @dirty = false
102
103     header = 
104       if opts[:header]
105         opts[:header]
106       else
107         header = @source.load_header @source_info
108         header.each { |k, v| header[k.downcase] = v }
109         header
110       end
111
112     %w(message-id date).each do |f|
113       raise MessageFormatError, "no #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header.include? f
114       raise MessageFormatError, "nil #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header[f]
115     end
116
117     begin
118       date = header["date"]
119       @date = (Time === date ? date : Time.parse(header["date"]))
120     rescue ArgumentError => e
121       raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
122     end
123
124     if(@subj = header["subject"])
125       @subj = @subj.gsub(/\s+/, " ").gsub(/\s+$/, "")
126     else
127       @subj = DEFAULT_SUBJECT
128     end
129     @from = Person.for header["from"]
130     @to = Person.for_several header["to"]
131     @cc = Person.for_several header["cc"]
132     @bcc = Person.for_several header["bcc"]
133     @id = header["message-id"]
134     @refs = (header["references"] || "").scan(/<(.*?)>/).flatten
135     @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
136     @replyto = Person.for header["reply-to"]
137     @list_address =
138       if header["list-post"]
139         @list_address = Person.for header["list-post"].gsub(/^<mailto:|>$/, "")
140       else
141         nil
142       end
143
144     @recipient_email = header["delivered-to"]
145     @status = header["status"]
146   end
147
148   def broken?; @source.nil?; end
149   def snippet; @snippet || to_chunks && @snippet; end
150   def is_list_message?; !@list_address.nil?; end
151   def is_draft?; DraftLoader === @source; end
152   def draft_filename
153     raise "not a draft" unless is_draft?
154     @source.fn_for_offset @source_info
155   end
156
157   def save index
158     return if broken?
159     index.update_message self if @dirty
160     @dirty = false
161   end
162
163   def has_label? t; @labels.member? t; end
164   def add_label t
165     return if @labels.member? t
166     @labels.push t
167     @dirty = true
168   end
169   def remove_label t
170     return unless @labels.member? t
171     @labels.delete t
172     @dirty = true
173   end
174
175   def recipients
176     @to + @cc + @bcc
177   end
178
179   def labels= l
180     @labels = l
181     @dirty = true
182   end
183
184   def to_chunks
185     if @body
186       [Text.new(@body.split("\n"))]
187     else
188       message_to_chunks @source.load_message(@source_info)
189     end
190   end
191
192   def raw_header
193     @source.raw_header @source_info
194   end
195
196   def raw_full_message
197     @source.raw_full_message @source_info
198   end
199
200   def content
201     [
202       from && "#{from.name} #{from.email}",
203       to.map { |p| "#{p.name} #{p.email}" },
204       cc.map { |p| "#{p.name} #{p.email}" },
205       bcc.map { |p| "#{p.name} #{p.email}" },
206       to_chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
207       Message.normalize_subj(subj),
208     ].flatten.compact.join " "
209   end
210
211   def basic_body_lines
212     to_chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
213   end
214
215   def basic_header_lines
216     ["From: #{@from.full_address}"] +
217       (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
218       (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
219       (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
220       ["Date: #{@date.rfc822}",
221        "Subject: #{@subj}"]
222   end
223
224 private
225
226   ## everything RubyMail-specific goes here.
227   def message_to_chunks m
228     ret = [] <<
229       case m.header.content_type
230       when "text/plain", nil
231         raise MessageFormatError, "no message body before decode" unless
232           m.body
233         body = m.decode or raise MessageFormatError, "no message body"
234         text_to_chunks body.gsub(/\t/, "    ").gsub(/\r/, "").split("\n")
235       when /^multipart\//
236         nil
237       else
238         disp = m.header["Content-Disposition"] || ""
239         Attachment.new m.header.content_type, disp.gsub(/[\s\n]+/, " "), m
240       end
241     
242     m.each_part { |p| ret << message_to_chunks(p) } if m.multipart?
243     ret.compact.flatten
244   end
245
246   ## parse the lines of text into chunk objects.  the heuristics here
247   ## need tweaking in some nice manner. TODO: move these heuristics
248   ## into the classes themselves.
249
250   def text_to_chunks lines
251     state = :text # one of :text, :quote, or :sig
252     chunks = []
253     chunk_lines = []
254
255     lines.each_with_index do |line, i|
256       nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
257       case state
258       when :text
259         newstate = nil
260         if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
261           newstate = :quote
262         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
263           newstate = :sig
264         elsif line =~ BLOCK_QUOTE_PATTERN
265           newstate = :block_quote
266         end
267         if newstate
268           chunks << Text.new(chunk_lines) unless chunk_lines.empty?
269           chunk_lines = [line]
270           state = newstate
271         else
272           chunk_lines << line
273         end
274       when :quote
275         newstate = nil
276         if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN || line =~ /^\s*$/
277           chunk_lines << line
278         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
279           newstate = :sig
280         else
281           newstate = :text
282         end
283         if newstate
284           if chunk_lines.empty?
285             # nothing
286           elsif chunk_lines.size == 1
287             chunks << Text.new(chunk_lines) # forget about one-line quotes
288           else
289             chunks << Quote.new(chunk_lines)
290           end
291           chunk_lines = [line]
292           state = newstate
293         end
294       when :block_quote
295         chunk_lines << line
296       when :sig
297         chunk_lines << line
298       end
299  
300       if state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) &&
301           line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
302         @snippet += " " unless @snippet.empty?
303         @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
304         @snippet = @snippet[0 ... SNIPPET_LEN]
305       end
306     end
307
308     ## final object
309     case state
310     when :quote, :block_quote
311       chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
312     when :text
313       chunks << Text.new(chunk_lines) unless chunk_lines.empty?
314     when :sig
315       chunks << Signature.new(chunk_lines) unless chunk_lines.empty?
316     end
317     chunks
318   end
319 end
320
321 end