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
138 def snippet; @snippet || (chunks && @snippet); end
139 def is_list_message?; !@list_address.nil?; end
140 def is_draft?; @source.is_a? DraftLoader; end
142 raise "not a draft" unless is_draft?
143 @source.fn_for_offset @source_info
146 def sanitize_message_id mid; mid.gsub(/\s/, "") end
149 index.sync_message self if @dirty
153 def has_label? t; @labels.member? t; end
155 return if @labels.member? t
160 return unless @labels.member? t
179 ## this is called when the message body needs to actually be loaded.
180 def load_from_source!
182 if @source.has_errors?
183 [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
186 ## we need to re-read the header because it contains information
187 ## that we don't store in the index. actually i think it's just
188 ## the mailing list address (if any), so this is kinda overkill.
189 ## i could just store that in the index, but i think there might
190 ## be other things like that in the future, and i'd rather not
192 ## actually, it's also the differentiation between to/cc/bcc,
193 ## so i will keep this.
194 parse_header @source.load_header(@source_info)
195 message_to_chunks @source.load_message(@source_info)
196 rescue SourceError, SocketError, MessageFormatError => e
197 Redwood::log "problem getting messages from #{@source}: #{e.message}"
198 ## we need force_to_top here otherwise this window will cover
199 ## up the error message one
201 Redwood::report_broken_sources :force_to_top => true
202 [Chunk::Text.new(error_message(e.message).split("\n"))]
207 def error_message msg
211 ***********************************************************************
212 An error occurred while loading this message. It is possible that
213 the source has changed, or (in the case of remote sources) is down.
214 You can check the log for errors, though hopefully an error window
215 should have popped up at some point.
217 The message location was:
218 #@source##@source_info
219 ***********************************************************************
221 The error message was:
226 ## wrap any source methods that might throw sourceerrors
227 def with_source_errors_handled
230 rescue SourceError => e
231 Redwood::log "problem getting messages from #{@source}: #{e.message}"
233 Redwood::report_broken_sources :force_to_top => true
234 error_message e.message
239 with_source_errors_handled { @source.raw_header @source_info }
243 with_source_errors_handled { @source.raw_message @source_info }
246 ## much faster than raw_message
247 def each_raw_message_line &b
248 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
251 ## returns all the content from a message that will be indexed
252 def indexable_content
255 from && from.indexable_content,
256 to.map { |p| p.indexable_content },
257 cc.map { |p| p.indexable_content },
258 bcc.map { |p| p.indexable_content },
259 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
260 Message.normalize_subj(subj),
261 ].flatten.compact.join " "
264 def quotable_body_lines
265 chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
268 def quotable_header_lines
269 ["From: #{@from.full_address}"] +
270 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
271 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
272 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
273 ["Date: #{@date.rfc822}",
279 ## here's where we handle decoding mime attachments. unfortunately
280 ## but unsurprisingly, the world of mime attachments is a bit of a
281 ## mess. as an empiricist, i'm basing the following behavior on
282 ## observed mail rather than on interpretations of rfcs, so probably
283 ## this will have to be tweaked.
285 ## the general behavior i want is: ignore content-disposition, at
286 ## least in so far as it suggests something being inline vs being an
287 ## attachment. (because really, that should be the recipient's
288 ## decision to make.) if a mime part is text/plain, OR if the user
289 ## decoding hook converts it, then decode it and display it
290 ## inline. for these decoded attachments, if it has associated
291 ## filename, then make it collapsable and individually saveable;
292 ## otherwise, treat it as regular body text.
294 ## everything else is just an attachment and is not displayed
297 ## so, in contrast to mutt, the user is not exposed to the workings
298 ## of the gruesome slaughterhouse and sausage factory that is a
299 ## mime-encoded message, but need only see the delicious end
302 def multipart_signed_to_chunks m
304 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
308 payload, signature = m.body
309 if signature.multipart?
310 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
314 ## this probably will never happen
315 if payload.header.content_type == "application/pgp-signature"
316 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
320 if signature.header.content_type != "application/pgp-signature"
321 ## unknown signature type; just ignore.
322 #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
326 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
329 def multipart_encrypted_to_chunks m
331 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
335 control, payload = m.body
336 if control.multipart?
337 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
341 if payload.header.content_type != "application/octet-stream"
342 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
346 if control.header.content_type != "application/pgp-encrypted"
347 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
351 decryptedm, sig, notice = CryptoManager.decrypt payload
352 children = message_to_chunks(decryptedm, true) if decryptedm
353 [notice, sig, children].flatten.compact
356 def message_to_chunks m, encrypted=false, sibling_types=[]
359 case m.header.content_type
360 when "multipart/signed"
361 multipart_signed_to_chunks m
362 when "multipart/encrypted"
363 multipart_encrypted_to_chunks m
367 sibling_types = m.body.map { |p| p.header.content_type }
368 chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
372 elsif m.header.content_type == "message/rfc822"
373 payload = RMail::Parser.read(m.body)
374 from = payload.header.from.first
375 from_person = from ? PersonManager.person_for(from.format) : nil
376 [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
379 ## first, paw through the headers looking for a filename
380 if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
382 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
385 ## haven't found one, but it's a non-text message. fake
388 ## TODO: make this less lame.
389 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
391 case m.header["Content-Type"]
392 when /text\/html/: "html"
393 when /image\/(.*)/: $1
396 ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
399 ## if there's a filename, we'll treat it as an attachment.
401 [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
403 ## otherwise, it's body text
405 body = Message.convert_from m.decode, m.charset if m.body
406 text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
411 def self.convert_from body, charset
412 charset = "utf-8" if charset =~ /UTF_?8/i
414 raise MessageFormatError, "RubyMail decode returned a null body" unless body
415 return body unless charset
416 Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
417 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
418 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
419 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
424 ## parse the lines of text into chunk objects. the heuristics here
425 ## need tweaking in some nice manner. TODO: move these heuristics
426 ## into the classes themselves.
427 def text_to_chunks lines, encrypted
428 state = :text # one of :text, :quote, or :sig
432 lines.each_with_index do |line, i|
433 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
439 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
441 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
443 elsif line =~ BLOCK_QUOTE_PATTERN
444 newstate = :block_quote
448 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
458 if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
460 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
467 if chunk_lines.empty?
470 chunks << Chunk::Quote.new(chunk_lines)
476 when :block_quote, :sig
480 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
482 @snippet += " " unless @snippet.empty?
483 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
484 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
485 @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
486 @snippet_contains_encrypted_content = true if encrypted
492 when :quote, :block_quote
493 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
495 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
497 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?