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 class CryptoSignature
116 attr_reader :lines, :description
118 def initialize payload, signature
120 @signature = signature
139 @status, @description, @lines = CryptoManager.verify(@payload, @signature) unless @status
143 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
144 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
145 QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
146 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
148 MAX_SIG_DISTANCE = 15 # lines from the end
150 DEFAULT_SENDER = "(missing sender)"
152 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
153 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
154 :source_info, :chunks
156 bool_reader :dirty, :source_marked_read
158 ## if you specify a :header, will use values from that. otherwise,
159 ## will try and load the header from the source.
161 @source = opts[:source] or raise ArgumentError, "source can't be nil"
162 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
163 @snippet = opts[:snippet] || ""
164 @have_snippet = !opts[:snippet].nil?
165 @labels = [] + (opts[:labels] || [])
169 parse_header(opts[:header] || @source.load_header(@source_info))
172 def parse_header header
173 header.each { |k, v| header[k.downcase] = v }
175 @from = PersonManager.person_for header["from"]
177 @id = header["message-id"]
179 @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
180 Redwood::log "faking message-id for message from #@from: #@id"
183 date = header["date"]
191 rescue ArgumentError => e
192 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
195 Redwood::log "faking date header for #{@id}"
199 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
200 @to = PersonManager.people_for header["to"]
201 @cc = PersonManager.people_for header["cc"]
202 @bcc = PersonManager.people_for header["bcc"]
203 @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
204 @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
205 @replyto = PersonManager.person_for header["reply-to"]
207 if header["list-post"]
208 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
213 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
214 @source_marked_read = header["status"] == "RO"
216 private :parse_header
218 def snippet; @snippet || chunks && @snippet; end
219 def is_list_message?; !@list_address.nil?; end
220 def is_draft?; @source.is_a? DraftLoader; end
222 raise "not a draft" unless is_draft?
223 @source.fn_for_offset @source_info
227 index.sync_message self if @dirty
231 def has_label? t; @labels.member? t; end
233 return if @labels.member? t
238 return unless @labels.member? t
252 ## this is called when the message body needs to actually be loaded.
253 def load_from_source!
255 if @source.has_errors?
256 [Text.new(error_message(@source.error.message.split("\n")))]
259 ## we need to re-read the header because it contains information
260 ## that we don't store in the index. actually i think it's just
261 ## the mailing list address (if any), so this is kinda overkill.
262 ## i could just store that in the index, but i think there might
263 ## be other things like that in the future, and i'd rather not
265 ## actually, it's also the differentiation between to/cc/bcc,
266 ## so i will keep this.
267 parse_header @source.load_header(@source_info)
268 message_to_chunks @source.load_message(@source_info)
269 rescue SourceError, SocketError, MessageFormatError => e
270 Redwood::log "problem getting messages from #{@source}: #{e.message}"
271 ## we need force_to_top here otherwise this window will cover
272 ## up the error message one
273 Redwood::report_broken_sources :force_to_top => true
274 [Text.new(error_message(e.message))]
279 def error_message msg
283 ***********************************************************************
284 An error occurred while loading this message. It is possible that
285 the source has changed, or (in the case of remote sources) is down.
286 You can check the log for errors, though hopefully an error window
287 should have popped up at some point.
289 The message location was:
290 #@source##@source_info
291 ***********************************************************************
293 The error message was:
298 def with_source_errors_handled
301 rescue SourceError => e
302 Redwood::log "problem getting messages from #{@source}: #{e.message}"
303 error_message e.message
308 with_source_errors_handled { @source.raw_header @source_info }
312 with_source_errors_handled { @source.raw_full_message @source_info }
315 ## much faster than raw_full_message
316 def each_raw_full_message_line &b
317 with_source_errors_handled { @source.each_raw_full_message_line(@source_info, &b) }
323 from && "#{from.name} #{from.email}",
324 to.map { |p| "#{p.name} #{p.email}" },
325 cc.map { |p| "#{p.name} #{p.email}" },
326 bcc.map { |p| "#{p.name} #{p.email}" },
327 chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
328 Message.normalize_subj(subj),
329 ].flatten.compact.join " "
333 chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
336 def basic_header_lines
337 ["From: #{@from.full_address}"] +
338 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
339 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
340 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
341 ["Date: #{@date.rfc822}",
347 ## here's where we handle decoding mime attachments. unfortunately
348 ## but unsurprisingly, the world of mime attachments is a bit of a
349 ## mess. as an empiricist, i'm basing the following behavior on
350 ## observed mail rather than on interpretations of rfcs, so probably
351 ## this will have to be tweaked.
353 ## the general behavior i want is: ignore content-disposition, at
354 ## least in so far as it suggests something being inline vs being an
355 ## attachment. (because really, that should be the recipient's
356 ## decision to make.) if a mime part is text/plain, OR if the user
357 ## decoding hook converts it, then decode it and display it
358 ## inline. for these decoded attachments, if it has associated
359 ## filename, then make it collapsable and individually saveable;
360 ## otherwise, treat it as regular body text.
362 ## everything else is just an attachment and is not displayed
365 ## so, in contrast to mutt, the user is not exposed to the workings
366 ## of the gruesome slaughterhouse and sausage factory that is a
367 ## mime-encoded message, but need only see the delicious end
370 def multipart_signed_to_chunks m
371 # Redwood::log ">> multipart SIGNED: #{m.header['Content-Type']}: #{m.body.size}"
373 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
377 payload, signature = m.body
378 if signature.multipart?
379 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
383 if payload.header.content_type == "application/pgp-signature"
384 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
388 if signature.header.content_type != "application/pgp-signature"
389 Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
393 [CryptoSignature.new(payload, signature), message_to_chunks(payload)].flatten
396 def message_to_chunks m, sibling_types=[]
398 chunks = multipart_signed_to_chunks(m) if m.header.content_type == "multipart/signed"
400 sibling_types = m.body.map { |p| p.header.content_type }
401 chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact
406 ## first, paw through the headers looking for a filename
407 if m.header["Content-Disposition"] &&
408 m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
410 elsif m.header["Content-Type"] &&
411 m.header["Content-Type"] =~ /name=(.*?)(;|$)/
414 ## haven't found one, but it's a non-text message. fake
416 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
417 "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
420 ## if there's a filename, we'll treat it as an attachment.
422 [Attachment.new(m.header.content_type, filename, m, sibling_types)]
424 ## otherwise, it's body text
426 body = Message.convert_from m.decode, m.charset
427 text_to_chunks body.normalize_whitespace.split("\n")
432 def self.convert_from body, charset
433 return body unless charset
436 Iconv.iconv($encoding, charset, body).join
437 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
438 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
439 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
444 ## parse the lines of text into chunk objects. the heuristics here
445 ## need tweaking in some nice manner. TODO: move these heuristics
446 ## into the classes themselves.
447 def text_to_chunks lines
448 state = :text # one of :text, :quote, or :sig
452 lines.each_with_index do |line, i|
453 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
459 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
461 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
463 elsif line =~ BLOCK_QUOTE_PATTERN
464 newstate = :block_quote
468 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
478 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
480 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
487 if chunk_lines.empty?
490 chunks << Quote.new(chunk_lines)
496 when :block_quote, :sig
500 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
501 @snippet += " " unless @snippet.empty?
502 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
503 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
509 when :quote, :block_quote
510 chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
512 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
514 chunks << Signature.new(chunk_lines) unless chunk_lines.empty?