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, SocketError => 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 error_message e.message
214 @source.raw_full_message @source_info
215 rescue SourceError => e
216 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.
269 def text_to_chunks lines
270 state = :text # one of :text, :quote, or :sig
274 lines.each_with_index do |line, i|
275 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
281 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
283 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
285 elsif line =~ BLOCK_QUOTE_PATTERN
286 newstate = :block_quote
290 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
300 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN || line =~ /^\s*$/
302 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
309 if chunk_lines.empty?
311 elsif chunk_lines.size == 1
312 chunks << Text.new(chunk_lines) # forget about one-line quotes
314 chunks << Quote.new(chunk_lines)
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
337 when :quote, :block_quote
338 chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
340 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
342 chunks << Signature.new(chunk_lines) unless chunk_lines.empty?