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, :attachments, :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] || [])
59 ## we need to initialize this. see comments in parse_header as to
63 parse_header(opts[:header] || @source.load_header(@source_info))
66 def parse_header header
67 header.keys.each { |k| header[k.downcase] = header[k] } # canonicalize
73 if header["message-id"]
74 sanitize_message_id header["message-id"]
76 fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
81 Person.from_address header["from"]
83 fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
84 Person.from_address fakename
87 Redwood::log "faking message-id for message from #@from: #{id}" if fakeid
88 Redwood::log "faking from for message #@id: #{fakename}" if fakename
98 rescue ArgumentError => e
99 Redwood::log "faking date header for #{@id} due to error parsing date #{header['date'].inspect}: #{e.message}"
103 Redwood::log "faking date header for #{@id}"
107 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
108 @to = Person.from_address_list header["to"]
109 @cc = Person.from_address_list header["cc"]
110 @bcc = Person.from_address_list header["bcc"]
112 ## before loading our full header from the source, we can actually
113 ## have some extra refs set by the UI. (this happens when the user
114 ## joins threads manually). so we will merge the current refs values
116 refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
117 @refs = (@refs + refs).uniq
118 @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
120 @replyto = Person.from_address header["reply-to"]
122 if header["list-post"]
123 @list_address = Person.from_address header["list-post"].gsub(/^<mailto:|>$/, "")
128 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
129 @source_marked_read = header["status"] == "RO"
130 @list_subscribe = header["list-subscribe"]
131 @list_unsubscribe = header["list-unsubscribe"]
133 private :parse_header
141 @dirty = true if @refs.delete ref
144 def snippet; @snippet || (chunks && @snippet); end
145 def is_list_message?; !@list_address.nil?; end
146 def is_draft?; @source.is_a? DraftLoader; end
148 raise "not a draft" unless is_draft?
149 @source.fn_for_offset @source_info
152 ## sanitize message ids by removing spaces and non-ascii characters.
153 ## also, truncate to 255 characters. all these steps are necessary
154 ## to make ferret happy. of course, we probably fuck up a couple
155 ## valid message ids as well. as long as we're consistent, this
156 ## should be fine, though.
158 ## also, mostly the message ids that are changed by this belong to
161 ## an alternative would be to SHA1 or MD5 all message ids on a regular basis.
163 def sanitize_message_id mid; mid.gsub(/(\s|[^\000-\177])+/, "")[0..254] end
167 index.sync_message self
172 def has_label? t; @labels.member? t; end
174 return if @labels.member? t
179 return unless @labels.member? t
198 ## this is called when the message body needs to actually be loaded.
199 def load_from_source!
201 if @source.has_errors?
202 [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
205 ## we need to re-read the header because it contains information
206 ## that we don't store in the index. actually i think it's just
207 ## the mailing list address (if any), so this is kinda overkill.
208 ## i could just store that in the index, but i think there might
209 ## be other things like that in the future, and i'd rather not
211 ## actually, it's also the differentiation between to/cc/bcc,
212 ## so i will keep this.
213 parse_header @source.load_header(@source_info)
214 message_to_chunks @source.load_message(@source_info)
215 rescue SourceError, SocketError, MessageFormatError => e
216 Redwood::log "problem getting messages from #{@source}: #{e.message}"
217 ## we need force_to_top here otherwise this window will cover
218 ## up the error message one
220 Redwood::report_broken_sources :force_to_top => true
221 [Chunk::Text.new(error_message(e.message).split("\n"))]
226 def error_message msg
230 ***********************************************************************
231 An error occurred while loading this message. It is possible that
232 the source has changed, or (in the case of remote sources) is down.
233 You can check the log for errors, though hopefully an error window
234 should have popped up at some point.
236 The message location was:
237 #@source##@source_info
238 ***********************************************************************
240 The error message was:
245 ## wrap any source methods that might throw sourceerrors
246 def with_source_errors_handled
249 rescue SourceError => e
250 Redwood::log "problem getting messages from #{@source}: #{e.message}"
252 Redwood::report_broken_sources :force_to_top => true
253 error_message e.message
258 with_source_errors_handled { @source.raw_header @source_info }
262 with_source_errors_handled { @source.raw_message @source_info }
265 ## much faster than raw_message
266 def each_raw_message_line &b
267 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
270 ## returns all the content from a message that will be indexed
271 def indexable_content
274 from && from.indexable_content,
275 to.map { |p| p.indexable_content },
276 cc.map { |p| p.indexable_content },
277 bcc.map { |p| p.indexable_content },
278 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
279 Message.normalize_subj(subj),
280 ].flatten.compact.join " "
283 def quotable_body_lines
284 chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
287 def quotable_header_lines
288 ["From: #{@from.full_address}"] +
289 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
290 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
291 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
292 ["Date: #{@date.rfc822}",
298 ## here's where we handle decoding mime attachments. unfortunately
299 ## but unsurprisingly, the world of mime attachments is a bit of a
300 ## mess. as an empiricist, i'm basing the following behavior on
301 ## observed mail rather than on interpretations of rfcs, so probably
302 ## this will have to be tweaked.
304 ## the general behavior i want is: ignore content-disposition, at
305 ## least in so far as it suggests something being inline vs being an
306 ## attachment. (because really, that should be the recipient's
307 ## decision to make.) if a mime part is text/plain, OR if the user
308 ## decoding hook converts it, then decode it and display it
309 ## inline. for these decoded attachments, if it has associated
310 ## filename, then make it collapsable and individually saveable;
311 ## otherwise, treat it as regular body text.
313 ## everything else is just an attachment and is not displayed
316 ## so, in contrast to mutt, the user is not exposed to the workings
317 ## of the gruesome slaughterhouse and sausage factory that is a
318 ## mime-encoded message, but need only see the delicious end
321 def multipart_signed_to_chunks m
323 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
327 payload, signature = m.body
328 if signature.multipart?
329 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
333 ## this probably will never happen
334 if payload.header.content_type == "application/pgp-signature"
335 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
339 if signature.header.content_type != "application/pgp-signature"
340 ## unknown signature type; just ignore.
341 #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
345 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
348 def multipart_encrypted_to_chunks m
350 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
354 control, payload = m.body
355 if control.multipart?
356 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
360 if payload.header.content_type != "application/octet-stream"
361 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
365 if control.header.content_type != "application/pgp-encrypted"
366 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
370 decryptedm, sig, notice = CryptoManager.decrypt payload
371 children = message_to_chunks(decryptedm, true) if decryptedm
372 [notice, sig, children].flatten.compact
375 def message_to_chunks m, encrypted=false, sibling_types=[]
378 case m.header.content_type
379 when "multipart/signed"
380 multipart_signed_to_chunks m
381 when "multipart/encrypted"
382 multipart_encrypted_to_chunks m
386 sibling_types = m.body.map { |p| p.header.content_type }
387 chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
391 elsif m.header.content_type == "message/rfc822"
392 payload = RMail::Parser.read(m.body)
393 from = payload.header.from.first
394 from_person = from ? Person.from_address(from.format) : nil
395 [Chunk::EnclosedMessage.new(from_person, payload.to_s)] +
396 message_to_chunks(payload, encrypted)
399 ## first, paw through the headers looking for a filename
400 if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
402 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
405 ## haven't found one, but it's a non-text message. fake
408 ## TODO: make this less lame.
409 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
411 case m.header["Content-Type"]
412 when /text\/html/: "html"
413 when /image\/(.*)/: $1
416 ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
419 ## if there's a filename, we'll treat it as an attachment.
421 # add this to the attachments list if its not a generated html
422 # attachment (should we allow images with generated names?).
423 # Lowercase the filename because searches are easier that way
424 @attachments.push filename.downcase unless filename =~ /^sup-attachment-/
425 add_label :attachment unless filename =~ /^sup-attachment-/
426 content_type = m.header.content_type || "application/unknown" # sometimes RubyMail gives us nil
427 [Chunk::Attachment.new(content_type, filename, m, sibling_types)]
429 ## otherwise, it's body text
431 body = Message.convert_from m.decode, m.charset if m.body
432 text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
437 def self.convert_from body, charset
439 raise MessageFormatError, "RubyMail decode returned a null body" unless body
440 return body unless charset
441 Iconv.easy_decode($encoding, charset, body)
442 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
443 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
444 File.open(File.join(BASE_DIR,"unable-to-decode.txt"), "w") { |f| f.write body }
449 ## parse the lines of text into chunk objects. the heuristics here
450 ## need tweaking in some nice manner. TODO: move these heuristics
451 ## into the classes themselves.
452 def text_to_chunks lines, encrypted
453 state = :text # one of :text, :quote, or :sig
457 lines.each_with_index do |line, i|
458 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
464 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
466 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
468 elsif line =~ BLOCK_QUOTE_PATTERN
469 newstate = :block_quote
473 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
483 if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
485 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
492 if chunk_lines.empty?
495 chunks << Chunk::Quote.new(chunk_lines)
501 when :block_quote, :sig
505 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
507 @snippet += " " unless @snippet.empty?
508 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
509 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
510 @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
511 @snippet_contains_encrypted_content = true if encrypted
517 when :quote, :block_quote
518 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
520 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
522 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?