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
60 Message.convert_from(@raw_content, encoded_content.charset).split("\n")
62 text = HookManager.run "mime-decode", :content_type => content_type,
63 :filename => lambda { write_to_disk },
64 :sibling_types => sibling_types
65 text.split("\n") if text
70 def inlineable?; !@lines.nil? end
74 system "/usr/bin/run-mailcap --action=view #{@content_type}:#{path} >& /dev/null"
78 ## used when viewing the attachment as text
80 @lines || @raw_content
86 file = Tempfile.new "redwood.attachment"
87 file.print @raw_content
97 @lines = lines.map { |l| l.chomp.wrap WRAP_LEN }.flatten
115 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
116 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
117 QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
118 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
120 MAX_SIG_DISTANCE = 15 # lines from the end
122 DEFAULT_SENDER = "(missing sender)"
124 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
125 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
126 :source_info, :chunks
128 bool_reader :dirty, :source_marked_read
130 ## if you specify a :header, will use values from that. otherwise,
131 ## will try and load the header from the source.
133 @source = opts[:source] or raise ArgumentError, "source can't be nil"
134 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
135 @snippet = opts[:snippet] || ""
136 @have_snippet = !opts[:snippet].nil?
137 @labels = [] + (opts[:labels] || [])
141 parse_header(opts[:header] || @source.load_header(@source_info))
144 def parse_header header
145 header.each { |k, v| header[k.downcase] = v }
147 @from = PersonManager.person_for header["from"]
149 @id = header["message-id"]
151 @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
152 Redwood::log "faking message-id for message from #@from: #@id"
155 date = header["date"]
163 rescue ArgumentError => e
164 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
167 Redwood::log "faking date header for #{@id}"
171 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
172 @to = PersonManager.people_for header["to"]
173 @cc = PersonManager.people_for header["cc"]
174 @bcc = PersonManager.people_for header["bcc"]
175 @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
176 @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
177 @replyto = PersonManager.person_for header["reply-to"]
179 if header["list-post"]
180 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
185 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
186 @source_marked_read = header["status"] == "RO"
188 private :parse_header
190 def snippet; @snippet || chunks && @snippet; end
191 def is_list_message?; !@list_address.nil?; end
192 def is_draft?; @source.is_a? DraftLoader; end
194 raise "not a draft" unless is_draft?
195 @source.fn_for_offset @source_info
199 index.sync_message self if @dirty
203 def has_label? t; @labels.member? t; end
205 return if @labels.member? t
210 return unless @labels.member? t
224 ## this is called when the message body needs to actually be loaded.
225 def load_from_source!
227 if @source.has_errors?
228 [Text.new(error_message(@source.error.message.split("\n")))]
231 ## we need to re-read the header because it contains information
232 ## that we don't store in the index. actually i think it's just
233 ## the mailing list address (if any), so this is kinda overkill.
234 ## i could just store that in the index, but i think there might
235 ## be other things like that in the future, and i'd rather not
237 ## actually, it's also the differentiation between to/cc/bcc,
238 ## so i will keep this.
239 parse_header @source.load_header(@source_info)
240 message_to_chunks @source.load_message(@source_info)
241 rescue SourceError, SocketError, MessageFormatError => e
242 Redwood::log "problem getting messages from #{@source}: #{e.message}"
243 ## we need force_to_top here otherwise this window will cover
244 ## up the error message one
245 Redwood::report_broken_sources :force_to_top => true
246 [Text.new(error_message(e.message))]
251 def error_message msg
255 ***********************************************************************
256 An error occurred while loading this message. It is possible that
257 the source has changed, or (in the case of remote sources) is down.
258 You can check the log for errors, though hopefully an error window
259 should have popped up at some point.
261 The message location was:
262 #@source##@source_info
263 ***********************************************************************
265 The error message was:
270 def with_source_errors_handled
273 rescue SourceError => e
274 Redwood::log "problem getting messages from #{@source}: #{e.message}"
275 error_message e.message
280 with_source_errors_handled { @source.raw_header @source_info }
284 with_source_errors_handled { @source.raw_full_message @source_info }
287 ## much faster than raw_full_message
288 def each_raw_full_message_line &b
289 with_source_errors_handled { @source.each_raw_full_message_line(@source_info, &b) }
295 from && "#{from.name} #{from.email}",
296 to.map { |p| "#{p.name} #{p.email}" },
297 cc.map { |p| "#{p.name} #{p.email}" },
298 bcc.map { |p| "#{p.name} #{p.email}" },
299 chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
300 Message.normalize_subj(subj),
301 ].flatten.compact.join " "
305 chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
308 def basic_header_lines
309 ["From: #{@from.full_address}"] +
310 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
311 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
312 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
313 ["Date: #{@date.rfc822}",
319 ## here's where we handle decoding mime attachments. unfortunately
320 ## but unsurprisingly, the world of mime attachments is a bit of a
321 ## mess. as an empiricist, i'm basing the following behavior on
322 ## observed mail rather than on interpretations of rfcs, so probably
323 ## this will have to be tweaked.
325 ## the general behavior i want is: ignore content-disposition, at
326 ## least in so far as it suggests something being inline vs being an
327 ## attachment. (because really, that should be the recipient's
328 ## decision to make.) if a mime part is text/plain, OR if the user
329 ## decoding hook converts it, then decode it and display it
330 ## inline. for these decoded attachments, if it has associated
331 ## filename, then make it collapsable and individually saveable;
332 ## otherwise, treat it as regular body text.
334 ## everything else is just an attachment and is not displayed
337 ## so, in contrast to mutt, the user is not exposed to the workings
338 ## of the gruesome slaughterhouse and sausage factory that is a
339 ## mime-encoded message, but need only see the delicious end
341 def message_to_chunks m, sibling_types=[]
343 sibling_types = m.body.map { |p| p.header.content_type }
344 m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact # recurse
347 ## first, paw through the headers looking for a filename
348 if m.header["Content-Disposition"] &&
349 m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
351 elsif m.header["Content-Type"] &&
352 m.header["Content-Type"] =~ /name=(.*?)(;|$)/
355 ## haven't found one, but it's a non-text message. fake
357 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
358 "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
361 ## if there's a filename, we'll treat it as an attachment.
363 [Attachment.new(m.header.content_type, filename, m, sibling_types)]
365 ## otherwise, it's body text
367 body = Message.convert_from m.decode, m.charset
368 text_to_chunks body.normalize_whitespace.split("\n")
373 def self.convert_from body, charset
374 return body unless charset
377 Iconv.iconv($encoding, charset, body).join
378 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
379 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
380 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
385 ## parse the lines of text into chunk objects. the heuristics here
386 ## need tweaking in some nice manner. TODO: move these heuristics
387 ## into the classes themselves.
388 def text_to_chunks lines
389 state = :text # one of :text, :quote, or :sig
393 lines.each_with_index do |line, i|
394 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
400 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
402 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
404 elsif line =~ BLOCK_QUOTE_PATTERN
405 newstate = :block_quote
409 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
419 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
421 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
428 if chunk_lines.empty?
431 chunks << Quote.new(chunk_lines)
437 when :block_quote, :sig
441 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
442 @snippet += " " unless @snippet.empty?
443 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
444 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
450 when :quote, :block_quote
451 chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
453 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
455 chunks << Signature.new(chunk_lines) unless chunk_lines.empty?