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?)
16 ## TODO: integrate with user's addressbook to render names
20 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
22 ## some utility methods
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
30 attr_reader :content_type, :desc, :filename
31 def initialize content_type, desc, part
32 @content_type = content_type
36 desc =~ /filename="(.*?)"/ && @filename = $1
41 @file = Tempfile.new "redwood.attachment"
46 ## TODO: handle unknown mime-types
47 system "/usr/bin/run-mailcap --action=view #{@content_type}:#{@file.path}"
50 def to_s; @part.decode; end
57 @lines = lines.map { |l| l.chomp.wrap 80 }.flatten
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)"
83 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
84 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
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 @labels = opts[:labels] || []
98 read_header(opts[:header] || @source.load_header(@source_info))
101 def read_header header
102 header.each { |k, v| header[k.downcase] = v }
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]
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}"
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"]
126 if header["list-post"]
127 @list_address = Person.for header["list-post"].gsub(/^<mailto:|>$/, "")
132 @recipient_email = header["delivered-to"]
133 @status = header["status"]
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
142 raise "not a draft" unless is_draft?
143 @source.fn_for_offset @source_info
148 index.update_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.
177 [Text.new(error_message(@source.broken_msg.split("\n")))]
180 read_header @source.load_header(@source_info)
181 message_to_chunks @source.load_message(@source_info)
182 rescue SourceError => e
183 [Text.new(error_message(e.message))]
188 def error_message msg
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.
199 The error message was:
206 @source.raw_header @source_info
207 rescue SourceError => e
208 [Text.new(error_message(e.message))]
214 @source.raw_full_message @source_info
215 rescue SourceError => e
216 [Text.new(error_message(e.message))]
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 " "
232 to_chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
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}",
246 ## everything RubyMail-specific goes here.
247 def message_to_chunks m
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
253 body = m.decode or raise MessageFormatError, "no message body"
254 text_to_chunks body.normalize_whitespace.split("\n")
258 disp = m.header["Content-Disposition"] || ""
259 Attachment.new m.header.content_type, disp.gsub(/[\s\n]+/, " "), m
262 m.each_part { |p| ret << message_to_chunks(p) } if m.multipart?
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.
270 def text_to_chunks lines
271 state = :text # one of :text, :quote, or :sig
275 lines.each_with_index do |line, i|
276 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
280 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
282 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
284 elsif line =~ BLOCK_QUOTE_PATTERN
285 newstate = :block_quote
288 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
296 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN || line =~ /^\s*$/
298 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
304 if chunk_lines.empty?
306 elsif chunk_lines.size == 1
307 chunks << Text.new(chunk_lines) # forget about one-line quotes
309 chunks << Quote.new(chunk_lines)
320 if state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) &&
321 line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
322 @snippet += " " unless @snippet.empty?
323 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
324 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
330 when :quote, :block_quote
331 chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
333 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
335 chunks << Signature.new(chunk_lines) unless chunk_lines.empty?