6 class MessageFormatError < StandardError; end
8 ## a Message is what's threaded.
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?)
17 WRAP_LEN = 80 # wrap at this width
18 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
20 ## some utility methods
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
28 attr_reader :content_type, :desc, :filename
29 def initialize content_type, desc, part
30 @content_type = content_type
34 desc =~ /filename="?(.*?)("|$)/ && @filename = $1
39 @file = Tempfile.new "redwood.attachment"
44 system "/usr/bin/run-mailcap --action=view #{@content_type}:#{@file.path} >& /dev/null"
48 def to_s; @part.decode; end
55 @lines = lines.map { |l| l.chomp.wrap WRAP_LEN }.flatten
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*--~--~-)/
79 MAX_SIG_DISTANCE = 15 # lines from the end
80 DEFAULT_SUBJECT = "(missing subject)"
81 DEFAULT_SENDER = "(missing sender)"
83 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
84 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
87 bool_reader :dirty, :source_marked_read
89 ## if you specify a :header, will use values from that. otherwise, will try and
90 ## load the header from the source.
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] || [])
100 read_header(opts[:header] || @source.load_header(@source_info))
103 def read_header header
104 header.each { |k, v| header[k.downcase] = v }
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]
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}"
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"]
128 if header["list-post"]
129 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
134 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
135 @source_marked_read = header["status"] == "RO"
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
143 raise "not a draft" unless is_draft?
144 @source.fn_for_offset @source_info
148 index.sync_message self if @dirty
152 def has_label? t; @labels.member? t; end
154 return if @labels.member? t
159 return unless @labels.member? t
173 ## this is called when the message body needs to actually be loaded.
174 def load_from_source!
176 if @source.has_errors?
177 [Text.new(error_message(@source.error.message.split("\n")))]
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
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))]
200 def error_message msg
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.
210 The message location was:
211 #@source##@source_info
212 ***********************************************************************
214 The error message was:
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
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)
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 " "
250 chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
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}",
264 ## (almost) everything rmail-specific goes here
265 def message_to_chunks m
267 m.body.map { |p| message_to_chunks p }.flatten.compact
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"))
276 disp = m.header["Content-Disposition"] || ""
277 [Attachment.new(m.header.content_type, disp.gsub(/[\s\n]+/, " "), m)]
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
290 lines.each_with_index do |line, i|
291 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
297 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
299 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
301 elsif line =~ BLOCK_QUOTE_PATTERN
302 newstate = :block_quote
306 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
316 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
318 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
325 if chunk_lines.empty?
328 chunks << Quote.new(chunk_lines)
334 when :block_quote, :sig
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
347 when :quote, :block_quote
348 chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
350 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
352 chunks << Signature.new(chunk_lines) unless chunk_lines.empty?