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 WRAP_LEN = 80 # wrap at this width
21 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
23 ## some utility methods
25 def normalize_subj s; s.gsub(RE_PATTERN, ""); end
26 def subj_is_reply? s; s =~ RE_PATTERN; end
27 def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
31 attr_reader :content_type, :desc, :filename
32 def initialize content_type, desc, part
33 @content_type = content_type
37 desc =~ /filename="(.*?)"/ && @filename = $1
42 @file = Tempfile.new "redwood.attachment"
47 ## TODO: handle unknown mime-types
48 system "/usr/bin/run-mailcap --action=view #{@content_type}:#{@file.path}"
52 def to_s; @part.decode; end
59 @lines = lines.map { |l| l.chomp.wrap WRAP_LEN }.flatten
77 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
78 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
79 QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
80 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
81 MAX_SIG_DISTANCE = 15 # lines from the end
82 DEFAULT_SUBJECT = "(missing subject)"
83 DEFAULT_SENDER = "(missing sender)"
85 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
86 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
89 bool_reader :dirty, :source_marked_read
91 ## if you specify a :header, will use values from that. otherwise, will try and
92 ## load the header from the source.
94 @source = opts[:source] or raise ArgumentError, "source can't be nil"
95 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
96 @snippet = opts[:snippet] || ""
97 @have_snippet = !opts[:snippet].nil?
98 @labels = opts[:labels] || []
102 read_header(opts[:header] || @source.load_header(@source_info))
105 def read_header header
106 header.each { |k, v| header[k.downcase] = v }
108 %w(message-id date).each do |f|
109 raise MessageFormatError, "no #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header.include? f
110 raise MessageFormatError, "nil #{f} field in header #{header.inspect} (source #@source offset #@source_info)" unless header[f]
114 date = header["date"]
115 @date = Time === date ? date : Time.parse(header["date"])
116 rescue ArgumentError => e
117 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
120 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
121 @from = Person.for header["from"]
122 @to = Person.for_several header["to"]
123 @cc = Person.for_several header["cc"]
124 @bcc = Person.for_several header["bcc"]
125 @id = header["message-id"]
126 @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
127 @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
128 @replyto = Person.for header["reply-to"]
130 if header["list-post"]
131 @list_address = Person.for header["list-post"].gsub(/^<mailto:|>$/, "")
136 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
137 @source_marked_read = header["status"] == "RO"
141 def snippet; @snippet || chunks && @snippet; end
142 def is_list_message?; !@list_address.nil?; end
143 def is_draft?; DraftLoader === @source; end
145 raise "not a draft" unless is_draft?
146 @source.fn_for_offset @source_info
150 index.sync_message self if @dirty
154 def has_label? t; @labels.member? t; end
156 return if @labels.member? t
161 return unless @labels.member? t
175 ## this is called when the message body needs to actually be loaded.
176 def load_from_source!
178 if @source.has_errors?
179 [Text.new(error_message(@source.error.message.split("\n")))]
182 ## we need to re-read the header because it contains information
183 ## that we don't store in the index. actually i think it's just
184 ## the mailing list address (if any), so this is kinda overkill.
185 ## i could just store that in the index, but i think there might
186 ## be other things like that in the future, and i'd rather not
188 ## actually, it's also the differentiation between to/cc/bcc,
189 ## so i will keep this.
190 read_header @source.load_header(@source_info)
191 message_to_chunks @source.load_message(@source_info)
192 rescue SourceError, SocketError, MessageFormatError => e
193 [Text.new(error_message(e.message))]
198 def error_message msg
202 ***********************************************************************
203 An error occurred while loading this message. It is possible that
204 the source has changed, or (in the case of remote sources) is down.
205 The message source and offset are: #@source##@source_info
206 ***********************************************************************
208 The error message was:
215 @source.raw_header @source_info
216 rescue SourceError => e
217 error_message e.message
223 @source.raw_full_message @source_info
224 rescue SourceError => e
225 error_message(e.message)
232 from && "#{from.name} #{from.email}",
233 to.map { |p| "#{p.name} #{p.email}" },
234 cc.map { |p| "#{p.name} #{p.email}" },
235 bcc.map { |p| "#{p.name} #{p.email}" },
236 chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
237 Message.normalize_subj(subj),
238 ].flatten.compact.join " "
242 chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
245 def basic_header_lines
246 ["From: #{@from.full_address}"] +
247 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
248 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
249 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
250 ["Date: #{@date.rfc822}",
256 ## everything RubyMail-specific goes here.
257 def message_to_chunks m
259 case m.header.content_type
260 when "text/plain", nil
261 m.body && body = m.decode or raise MessageFormatError, "For some bizarre reason, RubyMail was unable to parse this message."
262 text_to_chunks body.normalize_whitespace.split("\n")
266 disp = m.header["Content-Disposition"] || ""
267 Attachment.new m.header.content_type, disp.gsub(/[\s\n]+/, " "), m
270 m.each_part { |p| ret << message_to_chunks(p) } if m.multipart?
274 ## parse the lines of text into chunk objects. the heuristics here
275 ## need tweaking in some nice manner. TODO: move these heuristics
276 ## into the classes themselves.
277 def text_to_chunks lines
278 state = :text # one of :text, :quote, or :sig
282 lines.each_with_index do |line, i|
283 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
289 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
291 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
293 elsif line =~ BLOCK_QUOTE_PATTERN
294 newstate = :block_quote
298 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
308 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
310 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
317 if chunk_lines.empty?
319 elsif chunk_lines.size == 1
320 chunks << Text.new(chunk_lines) # forget about one-line quotes
322 chunks << Quote.new(chunk_lines)
335 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
336 @snippet += " " unless @snippet.empty?
337 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
338 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
344 when :quote, :block_quote
345 chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
347 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
349 chunks << Signature.new(chunk_lines) unless chunk_lines.empty?