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