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
126 def valid?; status == :valid end
129 return @status if @status
130 payload = Tempfile.new "redwood.payload"
131 signature = Tempfile.new "redwood.signature"
133 payload.write @payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n")
136 signature.write @signature.decode
139 cmd = "gpg --quiet --batch --no-verbose --verify --logger-fd 1 #{signature.path} #{payload.path}"
140 #Redwood::log "gpg: running: #{cmd}"
141 gpg_output = `#{cmd}`
142 #Redwood::log "got output: #{gpg_output.inspect}"
143 @lines = gpg_output.split(/\n/)
146 if gpg_output =~ /^gpg: (.* signature from .*$)/
149 "Unable to determine validity of cryptographic signature"
152 @status = ($? == 0 ? :valid : :invalid)
156 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
157 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
158 QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
159 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)/
161 MAX_SIG_DISTANCE = 15 # lines from the end
163 DEFAULT_SENDER = "(missing sender)"
165 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
166 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
167 :source_info, :chunks
169 bool_reader :dirty, :source_marked_read
171 ## if you specify a :header, will use values from that. otherwise,
172 ## will try and load the header from the source.
174 @source = opts[:source] or raise ArgumentError, "source can't be nil"
175 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
176 @snippet = opts[:snippet] || ""
177 @have_snippet = !opts[:snippet].nil?
178 @labels = [] + (opts[:labels] || [])
182 parse_header(opts[:header] || @source.load_header(@source_info))
185 def parse_header header
186 header.each { |k, v| header[k.downcase] = v }
188 @from = PersonManager.person_for header["from"]
190 @id = header["message-id"]
192 @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
193 Redwood::log "faking message-id for message from #@from: #@id"
196 date = header["date"]
204 rescue ArgumentError => e
205 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
208 Redwood::log "faking date header for #{@id}"
212 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
213 @to = PersonManager.people_for header["to"]
214 @cc = PersonManager.people_for header["cc"]
215 @bcc = PersonManager.people_for header["bcc"]
216 @refs = (header["references"] || "").gsub(/[<>]/, "").split(/\s+/).flatten
217 @replytos = (header["in-reply-to"] || "").scan(/<(.*?)>/).flatten
218 @replyto = PersonManager.person_for header["reply-to"]
220 if header["list-post"]
221 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
226 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
227 @source_marked_read = header["status"] == "RO"
229 private :parse_header
231 def snippet; @snippet || chunks && @snippet; end
232 def is_list_message?; !@list_address.nil?; end
233 def is_draft?; @source.is_a? DraftLoader; end
235 raise "not a draft" unless is_draft?
236 @source.fn_for_offset @source_info
240 index.sync_message self if @dirty
244 def has_label? t; @labels.member? t; end
246 return if @labels.member? t
251 return unless @labels.member? t
265 ## this is called when the message body needs to actually be loaded.
266 def load_from_source!
268 if @source.has_errors?
269 [Text.new(error_message(@source.error.message.split("\n")))]
272 ## we need to re-read the header because it contains information
273 ## that we don't store in the index. actually i think it's just
274 ## the mailing list address (if any), so this is kinda overkill.
275 ## i could just store that in the index, but i think there might
276 ## be other things like that in the future, and i'd rather not
278 ## actually, it's also the differentiation between to/cc/bcc,
279 ## so i will keep this.
280 parse_header @source.load_header(@source_info)
281 message_to_chunks @source.load_message(@source_info)
282 rescue SourceError, SocketError, MessageFormatError => e
283 Redwood::log "problem getting messages from #{@source}: #{e.message}"
284 ## we need force_to_top here otherwise this window will cover
285 ## up the error message one
286 Redwood::report_broken_sources :force_to_top => true
287 [Text.new(error_message(e.message))]
292 def error_message msg
296 ***********************************************************************
297 An error occurred while loading this message. It is possible that
298 the source has changed, or (in the case of remote sources) is down.
299 You can check the log for errors, though hopefully an error window
300 should have popped up at some point.
302 The message location was:
303 #@source##@source_info
304 ***********************************************************************
306 The error message was:
311 def with_source_errors_handled
314 rescue SourceError => e
315 Redwood::log "problem getting messages from #{@source}: #{e.message}"
316 error_message e.message
321 with_source_errors_handled { @source.raw_header @source_info }
325 with_source_errors_handled { @source.raw_full_message @source_info }
328 ## much faster than raw_full_message
329 def each_raw_full_message_line &b
330 with_source_errors_handled { @source.each_raw_full_message_line(@source_info, &b) }
336 from && "#{from.name} #{from.email}",
337 to.map { |p| "#{p.name} #{p.email}" },
338 cc.map { |p| "#{p.name} #{p.email}" },
339 bcc.map { |p| "#{p.name} #{p.email}" },
340 chunks.select { |c| c.is_a? Text }.map { |c| c.lines },
341 Message.normalize_subj(subj),
342 ].flatten.compact.join " "
346 chunks.find_all { |c| c.is_a?(Text) || c.is_a?(Quote) }.map { |c| c.lines }.flatten
349 def basic_header_lines
350 ["From: #{@from.full_address}"] +
351 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
352 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
353 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
354 ["Date: #{@date.rfc822}",
360 ## here's where we handle decoding mime attachments. unfortunately
361 ## but unsurprisingly, the world of mime attachments is a bit of a
362 ## mess. as an empiricist, i'm basing the following behavior on
363 ## observed mail rather than on interpretations of rfcs, so probably
364 ## this will have to be tweaked.
366 ## the general behavior i want is: ignore content-disposition, at
367 ## least in so far as it suggests something being inline vs being an
368 ## attachment. (because really, that should be the recipient's
369 ## decision to make.) if a mime part is text/plain, OR if the user
370 ## decoding hook converts it, then decode it and display it
371 ## inline. for these decoded attachments, if it has associated
372 ## filename, then make it collapsable and individually saveable;
373 ## otherwise, treat it as regular body text.
375 ## everything else is just an attachment and is not displayed
378 ## so, in contrast to mutt, the user is not exposed to the workings
379 ## of the gruesome slaughterhouse and sausage factory that is a
380 ## mime-encoded message, but need only see the delicious end
383 def multipart_signed_to_chunks m
384 # Redwood::log ">> multipart SIGNED: #{m.header['Content-Type']}: #{m.body.size}"
386 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
390 payload, signature = m.body
391 if payload.multipart? || signature.multipart?
392 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
396 if payload.header.content_type == "application/pgp-signature"
397 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
401 if signature.header.content_type != "application/pgp-signature"
402 Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
406 [CryptoSignature.new(payload, signature), message_to_chunks(payload)].flatten
409 def message_to_chunks m, sibling_types=[]
411 chunks = multipart_signed_to_chunks(m) if m.header.content_type == "multipart/signed"
413 sibling_types = m.body.map { |p| p.header.content_type }
414 chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact
419 ## first, paw through the headers looking for a filename
420 if m.header["Content-Disposition"] &&
421 m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
423 elsif m.header["Content-Type"] &&
424 m.header["Content-Type"] =~ /name=(.*?)(;|$)/
427 ## haven't found one, but it's a non-text message. fake
429 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
430 "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
433 ## if there's a filename, we'll treat it as an attachment.
435 [Attachment.new(m.header.content_type, filename, m, sibling_types)]
437 ## otherwise, it's body text
439 body = Message.convert_from m.decode, m.charset
440 text_to_chunks body.normalize_whitespace.split("\n")
445 def self.convert_from body, charset
446 return body unless charset
449 Iconv.iconv($encoding, charset, body).join
450 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
451 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
452 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
457 ## parse the lines of text into chunk objects. the heuristics here
458 ## need tweaking in some nice manner. TODO: move these heuristics
459 ## into the classes themselves.
460 def text_to_chunks lines
461 state = :text # one of :text, :quote, or :sig
465 lines.each_with_index do |line, i|
466 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
472 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
474 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
476 elsif line =~ BLOCK_QUOTE_PATTERN
477 newstate = :block_quote
481 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
491 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
493 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
500 if chunk_lines.empty?
503 chunks << Quote.new(chunk_lines)
509 when :block_quote, :sig
513 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
514 @snippet += " " unless @snippet.empty?
515 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
516 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
522 when :quote, :block_quote
523 chunks << Quote.new(chunk_lines) unless chunk_lines.empty?
525 chunks << Text.new(chunk_lines) unless chunk_lines.empty?
527 chunks << Signature.new(chunk_lines) unless chunk_lines.empty?