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 Redwood::log "faking date header for #{@id} due to error parsing date #{header['date'].inspect}: #{e.message}"
102 Redwood::log "faking date header for #{@id}"
106 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
107 @to = PersonManager.people_for header["to"]
108 @cc = PersonManager.people_for header["cc"]
109 @bcc = PersonManager.people_for header["bcc"]
111 ## before loading our full header from the source, we can actually
112 ## have some extra refs set by the UI. (this happens when the user
113 ## joins threads manually). so we will merge the current refs values
115 refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
116 @refs = (@refs + refs).uniq
117 @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
119 @replyto = PersonManager.person_for header["reply-to"]
121 if header["list-post"]
122 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
127 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
128 @source_marked_read = header["status"] == "RO"
129 @list_subscribe = header["list-subscribe"]
130 @list_unsubscribe = header["list-unsubscribe"]
132 private :parse_header
140 @dirty = true if @refs.delete ref
143 def snippet; @snippet || (chunks && @snippet); end
144 def is_list_message?; !@list_address.nil?; end
145 def is_draft?; @source.is_a? DraftLoader; end
147 raise "not a draft" unless is_draft?
148 @source.fn_for_offset @source_info
151 ## sanitize message ids by removing spaces and non-ascii characters.
152 ## also, truncate to 255 characters. all these steps are necessary
153 ## to make ferret happy. of course, we probably fuck up a couple
154 ## valid message ids as well. as long as we're consistent, this
155 ## should be fine, though.
157 ## also, mostly the message ids that are changed by this belong to
160 ## an alternative would be to SHA1 or MD5 all message ids on a regular basis.
162 def sanitize_message_id mid; mid.gsub(/(\s|[^\000-\177])+/, "")[0..254] end
166 index.sync_message self
171 def has_label? t; @labels.member? t; end
173 return if @labels.member? t
178 return unless @labels.member? t
197 ## this is called when the message body needs to actually be loaded.
198 def load_from_source!
200 if @source.has_errors?
201 [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
204 ## we need to re-read the header because it contains information
205 ## that we don't store in the index. actually i think it's just
206 ## the mailing list address (if any), so this is kinda overkill.
207 ## i could just store that in the index, but i think there might
208 ## be other things like that in the future, and i'd rather not
210 ## actually, it's also the differentiation between to/cc/bcc,
211 ## so i will keep this.
212 parse_header @source.load_header(@source_info)
213 message_to_chunks @source.load_message(@source_info)
214 rescue SourceError, SocketError, MessageFormatError => e
215 Redwood::log "problem getting messages from #{@source}: #{e.message}"
216 ## we need force_to_top here otherwise this window will cover
217 ## up the error message one
219 Redwood::report_broken_sources :force_to_top => true
220 [Chunk::Text.new(error_message(e.message).split("\n"))]
225 def error_message msg
229 ***********************************************************************
230 An error occurred while loading this message. It is possible that
231 the source has changed, or (in the case of remote sources) is down.
232 You can check the log for errors, though hopefully an error window
233 should have popped up at some point.
235 The message location was:
236 #@source##@source_info
237 ***********************************************************************
239 The error message was:
244 ## wrap any source methods that might throw sourceerrors
245 def with_source_errors_handled
248 rescue SourceError => e
249 Redwood::log "problem getting messages from #{@source}: #{e.message}"
251 Redwood::report_broken_sources :force_to_top => true
252 error_message e.message
257 with_source_errors_handled { @source.raw_header @source_info }
261 with_source_errors_handled { @source.raw_message @source_info }
264 ## much faster than raw_message
265 def each_raw_message_line &b
266 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
269 ## returns all the content from a message that will be indexed
270 def indexable_content
273 from && from.indexable_content,
274 to.map { |p| p.indexable_content },
275 cc.map { |p| p.indexable_content },
276 bcc.map { |p| p.indexable_content },
277 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
278 Message.normalize_subj(subj),
279 ].flatten.compact.join " "
282 def quotable_body_lines
283 chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
286 def quotable_header_lines
287 ["From: #{@from.full_address}"] +
288 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
289 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
290 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
291 ["Date: #{@date.rfc822}",
297 ## here's where we handle decoding mime attachments. unfortunately
298 ## but unsurprisingly, the world of mime attachments is a bit of a
299 ## mess. as an empiricist, i'm basing the following behavior on
300 ## observed mail rather than on interpretations of rfcs, so probably
301 ## this will have to be tweaked.
303 ## the general behavior i want is: ignore content-disposition, at
304 ## least in so far as it suggests something being inline vs being an
305 ## attachment. (because really, that should be the recipient's
306 ## decision to make.) if a mime part is text/plain, OR if the user
307 ## decoding hook converts it, then decode it and display it
308 ## inline. for these decoded attachments, if it has associated
309 ## filename, then make it collapsable and individually saveable;
310 ## otherwise, treat it as regular body text.
312 ## everything else is just an attachment and is not displayed
315 ## so, in contrast to mutt, the user is not exposed to the workings
316 ## of the gruesome slaughterhouse and sausage factory that is a
317 ## mime-encoded message, but need only see the delicious end
320 def multipart_signed_to_chunks m
322 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
326 payload, signature = m.body
327 if signature.multipart?
328 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
332 ## this probably will never happen
333 if payload.header.content_type == "application/pgp-signature"
334 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
338 if signature.header.content_type != "application/pgp-signature"
339 ## unknown signature type; just ignore.
340 #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
344 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
347 def multipart_encrypted_to_chunks m
349 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
353 control, payload = m.body
354 if control.multipart?
355 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
359 if payload.header.content_type != "application/octet-stream"
360 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
364 if control.header.content_type != "application/pgp-encrypted"
365 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
369 decryptedm, sig, notice = CryptoManager.decrypt payload
370 children = message_to_chunks(decryptedm, true) if decryptedm
371 [notice, sig, children].flatten.compact
374 def message_to_chunks m, encrypted=false, sibling_types=[]
377 case m.header.content_type
378 when "multipart/signed"
379 multipart_signed_to_chunks m
380 when "multipart/encrypted"
381 multipart_encrypted_to_chunks m
385 sibling_types = m.body.map { |p| p.header.content_type }
386 chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
390 elsif m.header.content_type == "message/rfc822"
391 payload = RMail::Parser.read(m.body)
392 from = payload.header.from.first
393 from_person = from ? PersonManager.person_for(from.format) : nil
394 [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
397 ## first, paw through the headers looking for a filename
398 if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
400 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
403 ## haven't found one, but it's a non-text message. fake
406 ## TODO: make this less lame.
407 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
409 case m.header["Content-Type"]
410 when /text\/html/: "html"
411 when /image\/(.*)/: $1
414 ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
417 ## if there's a filename, we'll treat it as an attachment.
419 [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
421 ## otherwise, it's body text
423 body = Message.convert_from m.decode, m.charset if m.body
424 text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
429 def self.convert_from body, charset
430 charset = "utf-8" if charset =~ /UTF_?8/i
432 raise MessageFormatError, "RubyMail decode returned a null body" unless body
433 return body unless charset
434 Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
435 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
436 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
437 File.open(File.join(BASE_DIR,"unable-to-decode.txt"), "w") { |f| f.write body }
442 ## parse the lines of text into chunk objects. the heuristics here
443 ## need tweaking in some nice manner. TODO: move these heuristics
444 ## into the classes themselves.
445 def text_to_chunks lines, encrypted
446 state = :text # one of :text, :quote, or :sig
450 lines.each_with_index do |line, i|
451 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
457 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
459 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
461 elsif line =~ BLOCK_QUOTE_PATTERN
462 newstate = :block_quote
466 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
476 if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
478 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
485 if chunk_lines.empty?
488 chunks << Chunk::Quote.new(chunk_lines)
494 when :block_quote, :sig
498 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
500 @snippet += " " unless @snippet.empty?
501 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
502 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
503 @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
504 @snippet_contains_encrypted_content = true if encrypted
510 when :quote, :block_quote
511 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
513 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
515 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?