7 class MessageFormatError < StandardError; end
9 ## a Message is what's threaded.
11 ## it is also where the parsing for quotes and signatures is done, but
12 ## that should be moved out to a separate class at some point (because
13 ## i would like, for example, to be able to add in a ruby-talk
14 ## specific module that would detect and link to /ruby-talk:\d+/
15 ## sequences in the text of an email. (how sweet would that be?)
18 WRAP_LEN = 80 # wrap at this width
19 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
21 HookManager.register "mime-decode", <<EOS
22 Executes when decoding a MIME attachment.
24 content_type: the content-type of the message
25 filename: the filename of the attachment as saved to disk (generated
26 on the fly, so don't call more than once)
27 sibling_types: if this attachment is part of a multipart MIME attachment,
28 an array of content-types for all attachments. Otherwise,
31 The decoded text of the attachment, or nil if not decoded.
35 ## some utility methods
37 def normalize_subj s; s.gsub(RE_PATTERN, ""); end
38 def subj_is_reply? s; s =~ RE_PATTERN; end
39 def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
43 ## encoded_content is still possible MIME-encoded
45 ## raw_content is after decoding but before being turned into
48 ## lines is array of inlineable text.
50 attr_reader :content_type, :filename, :lines, :raw_content
52 def initialize content_type, filename, encoded_content, sibling_types
53 @content_type = content_type
55 @raw_content = encoded_content.decode
56 charset = encoded_content.charset
58 if @content_type =~ /^text\/plain\b/
59 @lines = Message.convert_from(@raw_content, charset).split("\n")
61 text = HookManager.run "mime-decode", :content_type => content_type,
62 :filename => lambda { write_to_disk }, :sibling_types => sibling_types
63 @lines = text.split("\n") if text
67 def inlineable?; !@lines.nil? end
71 system "/usr/bin/run-mailcap --action=view #{@content_type}:#{path} >& /dev/null"
78 file = Tempfile.new "redwood.attachment"
79 file.print @raw_content
89 @lines = lines.map { |l| l.chomp.wrap WRAP_LEN }.flatten
107 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
108 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
109 QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
110 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
112 MAX_SIG_DISTANCE = 15 # lines from the end
114 DEFAULT_SENDER = "(missing sender)"
116 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
117 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
118 :source_info, :chunks
120 bool_reader :dirty, :source_marked_read
122 ## if you specify a :header, will use values from that. otherwise,
123 ## will try and load the header from the source.
125 @source = opts[:source] or raise ArgumentError, "source can't be nil"
126 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
127 @snippet = opts[:snippet] || ""
128 @have_snippet = !opts[:snippet].nil?
129 @labels = [] + (opts[:labels] || [])
133 parse_header(opts[:header] || @source.load_header(@source_info))
136 def parse_header header
137 header.each { |k, v| header[k.downcase] = v }
139 @from = PersonManager.person_for header["from"]
141 @id = header["message-id"]
143 @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
144 Redwood::log "faking message-id for message from #@from: #@id"
147 date = header["date"]
155 rescue ArgumentError => e
156 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
159 Redwood::log "faking date header for #{@id}"
163 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
164 @to = PersonManager.people_for header["to"]
165 @cc = PersonManager.people_for header["cc"]
166 @bcc = PersonManager.people_for header["bcc"]
167 @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
168 @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
169 @replyto = PersonManager.person_for header["reply-to"]
171 if header["list-post"]
172 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
177 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
178 @source_marked_read = header["status"] == "RO"
180 private :parse_header
182 def snippet; @snippet || chunks && @snippet; end
183 def is_list_message?; !@list_address.nil?; end
184 def is_draft?; @source.is_a? DraftLoader; end
186 raise "not a draft" unless is_draft?
187 @source.fn_for_offset @source_info
191 index.sync_message self if @dirty
195 def has_label? t; @labels.member? t; end
197 return if @labels.member? t
202 return unless @labels.member? t
216 ## this is called when the message body needs to actually be loaded.
217 def load_from_source!
219 if @source.has_errors?
220 [Text.new(error_message(@source.error.message.split("\n")))]
223 ## we need to re-read the header because it contains information
224 ## that we don't store in the index. actually i think it's just
225 ## the mailing list address (if any), so this is kinda overkill.
226 ## i could just store that in the index, but i think there might
227 ## be other things like that in the future, and i'd rather not
229 ## actually, it's also the differentiation between to/cc/bcc,
230 ## so i will keep this.
231 parse_header @source.load_header(@source_info)
232 message_to_chunks @source.load_message(@source_info)
233 rescue SourceError, SocketError, MessageFormatError => e
234 Redwood::log "problem getting messages from #{@source}: #{e.message}"
235 ## we need force_to_top here otherwise this window will cover
236 ## up the error message one
237 Redwood::report_broken_sources :force_to_top => true
238 [Text.new(error_message(e.message))]
243 def error_message msg
247 ***********************************************************************
248 An error occurred while loading this message. It is possible that
249 the source has changed, or (in the case of remote sources) is down.
250 You can check the log for errors, though hopefully an error window
251 should have popped up at some point.
253 The message location was:
254 #@source##@source_info
255 ***********************************************************************
257 The error message was:
264 @source.raw_header @source_info
265 rescue SourceError => e
266 Redwood::log "problem getting messages from #{@source}: #{e.message}"
267 error_message e.message
273 @source.raw_full_message @source_info
274 rescue SourceError => e
275 Redwood::log "problem getting messages from #{@source}: #{e.message}"
276 error_message(e.message)
283 from && "#{from.name} #{from.email}",
284 to.map { |p| "#{p.name} #{p.email}" },
285 cc.map { |p| "#{p.name} #{p.email}" },
286 bcc.map { |p| "#{p.name} #{p.email}" },
287 chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
288 Message.normalize_subj(subj),
289 ].flatten.compact.join " "
293 chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
296 def basic_header_lines
297 ["From: #{@from.full_address}"] +
298 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
299 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
300 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
301 ["Date: #{@date.rfc822}",
307 ## here's where we handle decoding mime attachments. unfortunately
308 ## but unsurprisingly, the world of mime attachments is a bit of a
309 ## mess. as an empiricist, i'm basing the following behavior on
310 ## observed mail rather than on interpretations of rfcs, so probably
311 ## this will have to be tweaked.
313 ## the general behavior i want is: ignore content-disposition, at
314 ## least in so far as it suggests something being inline vs being an
315 ## attachment. (because really, that should be the recipient's
316 ## decision to make.) if a mime part is text/plain, OR if the user
317 ## decoding hook converts it, then decode it and display it
318 ## inline. for these decoded attachments, if it has associated
319 ## filename, then make it collapsable and individually saveable;
320 ## otherwise, treat it as regular body text.
322 ## everything else is just an attachment and is not displayed
325 ## so, in contrast to mutt, the user is not exposed to the workings
326 ## of the gruesome slaughterhouse and sausage factory that is a
327 ## mime-encoded message, but need only see the delicious end
329 def message_to_chunks m, sibling_types=[]
331 sibling_types = m.body.map { |p| p.header.content_type }
332 m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact # recurse
335 ## first, paw through the headers looking for a filename
336 if m.header["Content-Disposition"] &&
337 m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
339 elsif m.header["Content-Type"] &&
340 m.header["Content-Type"] =~ /name=(.*?)(;|$)/
343 ## haven't found one, but it's a non-text message. fake
345 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
346 "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
349 ## if there's a filename, we'll treat it as an attachment.
351 [Attachment.new(m.header.content_type, filename, m, sibling_types)]
353 ## otherwise, it's body text
355 body = Message.convert_from m.body, m.charset
356 text_to_chunks body.normalize_whitespace.split("\n")
361 def self.convert_from body, charset
362 return body unless charset
365 Iconv.iconv($encoding, charset, body).join
366 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
367 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
368 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
373 ## parse the lines of text into chunk objects. the heuristics here
374 ## need tweaking in some nice manner. TODO: move these heuristics
375 ## into the classes themselves.
376 def text_to_chunks lines
377 state = :text # one of :text, :quote, or :sig
381 lines.each_with_index do |line, i|
382 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
388 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
390 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
392 elsif line =~ BLOCK_QUOTE_PATTERN
393 newstate = :block_quote
397 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
407 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
409 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
416 if chunk_lines.empty?
419 chunks << Quote.new(chunk_lines)
425 when :block_quote, :sig
429 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
430 @snippet += " " unless @snippet.empty?
431 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
432 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
438 when :quote, :block_quote
439 chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
441 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
443 chunks << Signature.new(chunk_lines) unless chunk_lines.empty?