]> git.cworth.org Git - sup/blob - lib/sup/message.rb
minor documentation/comment updates
[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 class Message
16   SNIPPET_LEN = 80
17   WRAP_LEN = 80 # wrap at this width
18   RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
19     
20   ## some utility methods
21   class << self
22     def normalize_subj s; s.gsub(RE_PATTERN, ""); end
23     def subj_is_reply? s; s =~ RE_PATTERN; end
24     def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
25   end
26
27   class Attachment
28     attr_reader :content_type, :desc, :filename
29     def initialize content_type, desc, part
30       @content_type = content_type
31       @desc = desc
32       @part = part
33       @file = nil
34       desc =~ /filename="?(.*?)("|$)/ && @filename = $1
35     end
36
37     def view!
38       unless @file
39         @file = Tempfile.new "redwood.attachment"
40         @file.print self
41         @file.close
42       end
43
44       system "/usr/bin/run-mailcap --action=view #{@content_type}:#{@file.path} >& /dev/null"
45       $? == 0
46     end
47
48     def to_s; @part.decode; end
49   end
50
51   class Text
52     attr_reader :lines
53     def initialize lines
54       ## do some wrapping
55       @lines = lines.map { |l| l.chomp.wrap WRAP_LEN }.flatten
56     end
57   end
58
59   class Quote
60     attr_reader :lines
61     def initialize lines
62       @lines = lines
63     end
64   end
65
66   class Signature
67     attr_reader :lines
68     def initialize lines
69       @lines = lines
70     end
71   end
72
73
74   QUOTE_PATTERN = /^\s{0,4}[>|\}]/
75   BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
76   QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
77   SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
78
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, :chunks
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     @chunks = nil
99
100     read_header(opts[:header] || @source.load_header(@source_info))
101   end
102
103   def read_header header
104     header.each { |k, v| header[k.downcase] = v }
105
106     %w(message-id date).each do |f|
107       raise MessageFormatError, "no #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header.include? f
108       raise MessageFormatError, "nil #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header[f]
109     end
110
111     begin
112       date = header["date"]
113       @date = Time === date ? date : Time.parse(header["date"])
114     rescue ArgumentError => e
115       raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
116     end
117
118     @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
119     @from = PersonManager.person_for header["from"]
120     @to = PersonManager.people_for header["to"]
121     @cc = PersonManager.people_for header["cc"]
122     @bcc = PersonManager.people_for header["bcc"]
123     @id = header["message-id"]
124     @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
125     @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
126     @replyto = PersonManager.person_for header["reply-to"]
127     @list_address =
128       if header["list-post"]
129         @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
130       else
131         nil
132       end
133
134     @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
135     @source_marked_read = header["status"] == "RO"
136   end
137   private :read_header
138
139   def snippet; @snippet || chunks && @snippet; end
140   def is_list_message?; !@list_address.nil?; end
141   def is_draft?; @source.is_a? DraftLoader; 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     index.sync_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 load_from_source!
175     @chunks ||=
176       if @source.has_errors?
177         [Text.new(error_message(@source.error.message.split("\n")))]
178       else
179         begin
180           ## we need to re-read the header because it contains information
181           ## that we don't store in the index. actually i think it's just
182           ## the mailing list address (if any), so this is kinda overkill.
183           ## i could just store that in the index, but i think there might
184           ## be other things like that in the future, and i'd rather not
185           ## bloat the index.
186           ## actually, it's also the differentiation between to/cc/bcc,
187           ## so i will keep this.
188           read_header @source.load_header(@source_info)
189           message_to_chunks @source.load_message(@source_info)
190         rescue SourceError, SocketError, MessageFormatError => e
191           Redwood::log "problem getting messages from #{@source}: #{e.message}"
192           ## we need force_to_top here otherwise this window will cover
193           ## up the error message one
194           Redwood::report_broken_sources :force_to_top => true
195           [Text.new(error_message(e.message))]
196         end
197       end
198   end
199
200   def error_message msg
201     <<EOS
202 #@snippet...
203
204 ***********************************************************************
205  An error occurred while loading this message. It is possible that
206  the source has changed, or (in the case of remote sources) is down.
207  You can check the log for errors, though hopefully an error window
208  should have popped up at some point.
209
210  The message location was:
211  #@source##@source_info
212 ***********************************************************************
213
214 The error message was:
215   #{msg}
216 EOS
217   end
218
219   def raw_header
220     begin
221       @source.raw_header @source_info
222     rescue SourceError => e
223       Redwood::log "problem getting messages from #{@source}: #{e.message}"
224       error_message e.message
225     end
226   end
227
228   def raw_full_message
229     begin
230       @source.raw_full_message @source_info
231     rescue SourceError => e
232       Redwood::log "problem getting messages from #{@source}: #{e.message}"
233       error_message(e.message)
234     end
235   end
236
237   def content
238     load_from_source!
239     [
240       from && "#{from.name} #{from.email}",
241       to.map { |p| "#{p.name} #{p.email}" },
242       cc.map { |p| "#{p.name} #{p.email}" },
243       bcc.map { |p| "#{p.name} #{p.email}" },
244       chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
245       Message.normalize_subj(subj),
246     ].flatten.compact.join " "
247   end
248
249   def basic_body_lines
250     chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
251   end
252
253   def basic_header_lines
254     ["From: #{@from.full_address}"] +
255       (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
256       (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
257       (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
258       ["Date: #{@date.rfc822}",
259        "Subject: #{@subj}"]
260   end
261
262 private
263
264   ## (almost) everything rmail-specific goes here
265   def message_to_chunks m
266     if m.multipart?
267       m.body.map { |p| message_to_chunks p }.flatten.compact
268     else
269       case m.header.content_type
270       when "text/plain", nil
271         m.body && body = m.decode or raise MessageFormatError, "For some bizarre reason, RubyMail was unable to parse this message."
272         text_to_chunks(body.normalize_whitespace.split("\n"))
273       when /^multipart\//
274         []
275       else
276         disp = m.header["Content-Disposition"] || ""
277         [Attachment.new(m.header.content_type, disp.gsub(/[\s\n]+/, " "), m)]
278       end
279     end
280   end
281
282   ## parse the lines of text into chunk objects.  the heuristics here
283   ## need tweaking in some nice manner. TODO: move these heuristics
284   ## into the classes themselves.
285   def text_to_chunks lines
286     state = :text # one of :text, :quote, or :sig
287     chunks = []
288     chunk_lines = []
289
290     lines.each_with_index do |line, i|
291       nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
292
293       case state
294       when :text
295         newstate = nil
296
297         if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
298           newstate = :quote
299         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
300           newstate = :sig
301         elsif line =~ BLOCK_QUOTE_PATTERN
302           newstate = :block_quote
303         end
304
305         if newstate
306           chunks << Text.new(chunk_lines) unless chunk_lines.empty?
307           chunk_lines = [line]
308           state = newstate
309         else
310           chunk_lines << line
311         end
312
313       when :quote
314         newstate = nil
315
316         if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
317           chunk_lines << line
318         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
319           newstate = :sig
320         else
321           newstate = :text
322         end
323
324         if newstate
325           if chunk_lines.empty?
326             # nothing
327           else
328             chunks << Quote.new(chunk_lines)
329           end
330           chunk_lines = [line]
331           state = newstate
332         end
333
334       when :block_quote, :sig
335         chunk_lines << line
336       end
337  
338       if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
339         @snippet += " " unless @snippet.empty?
340         @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
341         @snippet = @snippet[0 ... SNIPPET_LEN].chomp
342       end
343     end
344
345     ## final object
346     case state
347     when :quote, :block_quote
348       chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
349     when :text
350       chunks << Text.new(chunk_lines) unless chunk_lines.empty?
351     when :sig
352       chunks << Signature.new(chunk_lines) unless chunk_lines.empty?
353     end
354     chunks
355   end
356 end
357
358 end