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 ## this class cathces all source exceptions. if the underlying source throws
17 ## an error, it is caught and handled.
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
30 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
31 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
32 QUOTE_START_PATTERN = /\w.*:$/
33 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
35 MAX_SIG_DISTANCE = 15 # lines from the end
37 DEFAULT_SENDER = "(missing sender)"
39 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
40 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
41 :source_info, :list_subscribe, :list_unsubscribe
43 bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content
45 ## if you specify a :header, will use values from that. otherwise,
46 ## will try and load the header from the source.
48 @source = opts[:source] or raise ArgumentError, "source can't be nil"
49 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
50 @snippet = opts[:snippet]
51 @snippet_contains_encrypted_content = false
52 @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?)
53 @labels = [] + (opts[:labels] || [])
58 ## we need to initialize this. see comments in parse_header as to
62 parse_header(opts[:header] || @source.load_header(@source_info))
65 def parse_header header
66 header.each { |k, v| header[k.downcase] = v }
72 if header["message-id"]
73 sanitize_message_id header["message-id"]
75 fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
80 PersonManager.person_for header["from"]
82 fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
83 PersonManager.person_for fakename
86 Redwood::log "faking message-id for message from #@from: #{id}" if fakeid
87 Redwood::log "faking from for message #@id: #{fakename}" if fakename
97 rescue ArgumentError => e
98 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
101 Redwood::log "faking date header for #{@id}"
105 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
106 @to = PersonManager.people_for header["to"]
107 @cc = PersonManager.people_for header["cc"]
108 @bcc = PersonManager.people_for header["bcc"]
110 ## before loading our full header from the source, we can actually
111 ## have some extra refs set by the UI. (this happens when the user
112 ## joins threads manually). so we will merge the current refs values
114 refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
115 @refs = (@refs + refs).uniq
116 @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
118 @replyto = PersonManager.person_for header["reply-to"]
120 if header["list-post"]
121 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
126 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
127 @source_marked_read = header["status"] == "RO"
128 @list_subscribe = header["list-subscribe"]
129 @list_unsubscribe = header["list-unsubscribe"]
131 private :parse_header
139 @dirty = true if @refs.delete ref
142 def snippet; @snippet || (chunks && @snippet); end
143 def is_list_message?; !@list_address.nil?; end
144 def is_draft?; @source.is_a? DraftLoader; end
146 raise "not a draft" unless is_draft?
147 @source.fn_for_offset @source_info
150 def sanitize_message_id mid; mid.gsub(/\s/, "") end
154 index.sync_message self
159 def has_label? t; @labels.member? t; end
161 return if @labels.member? t
166 return unless @labels.member? t
185 ## this is called when the message body needs to actually be loaded.
186 def load_from_source!
188 if @source.has_errors?
189 [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
192 ## we need to re-read the header because it contains information
193 ## that we don't store in the index. actually i think it's just
194 ## the mailing list address (if any), so this is kinda overkill.
195 ## i could just store that in the index, but i think there might
196 ## be other things like that in the future, and i'd rather not
198 ## actually, it's also the differentiation between to/cc/bcc,
199 ## so i will keep this.
200 parse_header @source.load_header(@source_info)
201 message_to_chunks @source.load_message(@source_info)
202 rescue SourceError, SocketError, MessageFormatError => e
203 Redwood::log "problem getting messages from #{@source}: #{e.message}"
204 ## we need force_to_top here otherwise this window will cover
205 ## up the error message one
207 Redwood::report_broken_sources :force_to_top => true
208 [Chunk::Text.new(error_message(e.message).split("\n"))]
213 def error_message msg
217 ***********************************************************************
218 An error occurred while loading this message. It is possible that
219 the source has changed, or (in the case of remote sources) is down.
220 You can check the log for errors, though hopefully an error window
221 should have popped up at some point.
223 The message location was:
224 #@source##@source_info
225 ***********************************************************************
227 The error message was:
232 ## wrap any source methods that might throw sourceerrors
233 def with_source_errors_handled
236 rescue SourceError => e
237 Redwood::log "problem getting messages from #{@source}: #{e.message}"
239 Redwood::report_broken_sources :force_to_top => true
240 error_message e.message
245 with_source_errors_handled { @source.raw_header @source_info }
249 with_source_errors_handled { @source.raw_message @source_info }
252 ## much faster than raw_message
253 def each_raw_message_line &b
254 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
257 ## returns all the content from a message that will be indexed
258 def indexable_content
261 from && from.indexable_content,
262 to.map { |p| p.indexable_content },
263 cc.map { |p| p.indexable_content },
264 bcc.map { |p| p.indexable_content },
265 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
266 Message.normalize_subj(subj),
267 ].flatten.compact.join " "
270 def quotable_body_lines
271 chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
274 def quotable_header_lines
275 ["From: #{@from.full_address}"] +
276 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
277 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
278 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
279 ["Date: #{@date.rfc822}",
285 ## here's where we handle decoding mime attachments. unfortunately
286 ## but unsurprisingly, the world of mime attachments is a bit of a
287 ## mess. as an empiricist, i'm basing the following behavior on
288 ## observed mail rather than on interpretations of rfcs, so probably
289 ## this will have to be tweaked.
291 ## the general behavior i want is: ignore content-disposition, at
292 ## least in so far as it suggests something being inline vs being an
293 ## attachment. (because really, that should be the recipient's
294 ## decision to make.) if a mime part is text/plain, OR if the user
295 ## decoding hook converts it, then decode it and display it
296 ## inline. for these decoded attachments, if it has associated
297 ## filename, then make it collapsable and individually saveable;
298 ## otherwise, treat it as regular body text.
300 ## everything else is just an attachment and is not displayed
303 ## so, in contrast to mutt, the user is not exposed to the workings
304 ## of the gruesome slaughterhouse and sausage factory that is a
305 ## mime-encoded message, but need only see the delicious end
308 def multipart_signed_to_chunks m
310 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
314 payload, signature = m.body
315 if signature.multipart?
316 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
320 ## this probably will never happen
321 if payload.header.content_type == "application/pgp-signature"
322 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
326 if signature.header.content_type != "application/pgp-signature"
327 ## unknown signature type; just ignore.
328 #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
332 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
335 def multipart_encrypted_to_chunks m
337 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
341 control, payload = m.body
342 if control.multipart?
343 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
347 if payload.header.content_type != "application/octet-stream"
348 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
352 if control.header.content_type != "application/pgp-encrypted"
353 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
357 decryptedm, sig, notice = CryptoManager.decrypt payload
358 children = message_to_chunks(decryptedm, true) if decryptedm
359 [notice, sig, children].flatten.compact
362 def message_to_chunks m, encrypted=false, sibling_types=[]
365 case m.header.content_type
366 when "multipart/signed"
367 multipart_signed_to_chunks m
368 when "multipart/encrypted"
369 multipart_encrypted_to_chunks m
373 sibling_types = m.body.map { |p| p.header.content_type }
374 chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
378 elsif m.header.content_type == "message/rfc822"
379 payload = RMail::Parser.read(m.body)
380 from = payload.header.from.first
381 from_person = from ? PersonManager.person_for(from.format) : nil
382 [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
385 ## first, paw through the headers looking for a filename
386 if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
388 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
391 ## haven't found one, but it's a non-text message. fake
394 ## TODO: make this less lame.
395 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
397 case m.header["Content-Type"]
398 when /text\/html/: "html"
399 when /image\/(.*)/: $1
402 ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
405 ## if there's a filename, we'll treat it as an attachment.
407 [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
409 ## otherwise, it's body text
411 body = Message.convert_from m.decode, m.charset if m.body
412 text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
417 def self.convert_from body, charset
418 charset = "utf-8" if charset =~ /UTF_?8/i
420 raise MessageFormatError, "RubyMail decode returned a null body" unless body
421 return body unless charset
422 Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
423 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
424 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
425 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
430 ## parse the lines of text into chunk objects. the heuristics here
431 ## need tweaking in some nice manner. TODO: move these heuristics
432 ## into the classes themselves.
433 def text_to_chunks lines, encrypted
434 state = :text # one of :text, :quote, or :sig
438 lines.each_with_index do |line, i|
439 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
445 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
447 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
449 elsif line =~ BLOCK_QUOTE_PATTERN
450 newstate = :block_quote
454 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
464 if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
466 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
473 if chunk_lines.empty?
476 chunks << Chunk::Quote.new(chunk_lines)
482 when :block_quote, :sig
486 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
488 @snippet += " " unless @snippet.empty?
489 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
490 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
491 @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
492 @snippet_contains_encrypted_content = true if encrypted
498 when :quote, :block_quote
499 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
501 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
503 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?