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 def sanitize_message_id mid; mid.gsub(/\s+/, "")[0..254] end
155 index.sync_message self
160 def has_label? t; @labels.member? t; end
162 return if @labels.member? t
167 return unless @labels.member? t
186 ## this is called when the message body needs to actually be loaded.
187 def load_from_source!
189 if @source.has_errors?
190 [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
193 ## we need to re-read the header because it contains information
194 ## that we don't store in the index. actually i think it's just
195 ## the mailing list address (if any), so this is kinda overkill.
196 ## i could just store that in the index, but i think there might
197 ## be other things like that in the future, and i'd rather not
199 ## actually, it's also the differentiation between to/cc/bcc,
200 ## so i will keep this.
201 parse_header @source.load_header(@source_info)
202 message_to_chunks @source.load_message(@source_info)
203 rescue SourceError, SocketError, MessageFormatError => e
204 Redwood::log "problem getting messages from #{@source}: #{e.message}"
205 ## we need force_to_top here otherwise this window will cover
206 ## up the error message one
208 Redwood::report_broken_sources :force_to_top => true
209 [Chunk::Text.new(error_message(e.message).split("\n"))]
214 def error_message msg
218 ***********************************************************************
219 An error occurred while loading this message. It is possible that
220 the source has changed, or (in the case of remote sources) is down.
221 You can check the log for errors, though hopefully an error window
222 should have popped up at some point.
224 The message location was:
225 #@source##@source_info
226 ***********************************************************************
228 The error message was:
233 ## wrap any source methods that might throw sourceerrors
234 def with_source_errors_handled
237 rescue SourceError => e
238 Redwood::log "problem getting messages from #{@source}: #{e.message}"
240 Redwood::report_broken_sources :force_to_top => true
241 error_message e.message
246 with_source_errors_handled { @source.raw_header @source_info }
250 with_source_errors_handled { @source.raw_message @source_info }
253 ## much faster than raw_message
254 def each_raw_message_line &b
255 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
258 ## returns all the content from a message that will be indexed
259 def indexable_content
262 from && from.indexable_content,
263 to.map { |p| p.indexable_content },
264 cc.map { |p| p.indexable_content },
265 bcc.map { |p| p.indexable_content },
266 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
267 Message.normalize_subj(subj),
268 ].flatten.compact.join " "
271 def quotable_body_lines
272 chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
275 def quotable_header_lines
276 ["From: #{@from.full_address}"] +
277 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
278 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
279 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
280 ["Date: #{@date.rfc822}",
286 ## here's where we handle decoding mime attachments. unfortunately
287 ## but unsurprisingly, the world of mime attachments is a bit of a
288 ## mess. as an empiricist, i'm basing the following behavior on
289 ## observed mail rather than on interpretations of rfcs, so probably
290 ## this will have to be tweaked.
292 ## the general behavior i want is: ignore content-disposition, at
293 ## least in so far as it suggests something being inline vs being an
294 ## attachment. (because really, that should be the recipient's
295 ## decision to make.) if a mime part is text/plain, OR if the user
296 ## decoding hook converts it, then decode it and display it
297 ## inline. for these decoded attachments, if it has associated
298 ## filename, then make it collapsable and individually saveable;
299 ## otherwise, treat it as regular body text.
301 ## everything else is just an attachment and is not displayed
304 ## so, in contrast to mutt, the user is not exposed to the workings
305 ## of the gruesome slaughterhouse and sausage factory that is a
306 ## mime-encoded message, but need only see the delicious end
309 def multipart_signed_to_chunks m
311 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
315 payload, signature = m.body
316 if signature.multipart?
317 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
321 ## this probably will never happen
322 if payload.header.content_type == "application/pgp-signature"
323 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
327 if signature.header.content_type != "application/pgp-signature"
328 ## unknown signature type; just ignore.
329 #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
333 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
336 def multipart_encrypted_to_chunks m
338 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
342 control, payload = m.body
343 if control.multipart?
344 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
348 if payload.header.content_type != "application/octet-stream"
349 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
353 if control.header.content_type != "application/pgp-encrypted"
354 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
358 decryptedm, sig, notice = CryptoManager.decrypt payload
359 children = message_to_chunks(decryptedm, true) if decryptedm
360 [notice, sig, children].flatten.compact
363 def message_to_chunks m, encrypted=false, sibling_types=[]
366 case m.header.content_type
367 when "multipart/signed"
368 multipart_signed_to_chunks m
369 when "multipart/encrypted"
370 multipart_encrypted_to_chunks m
374 sibling_types = m.body.map { |p| p.header.content_type }
375 chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
379 elsif m.header.content_type == "message/rfc822"
380 payload = RMail::Parser.read(m.body)
381 from = payload.header.from.first
382 from_person = from ? PersonManager.person_for(from.format) : nil
383 [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
386 ## first, paw through the headers looking for a filename
387 if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
389 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
392 ## haven't found one, but it's a non-text message. fake
395 ## TODO: make this less lame.
396 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
398 case m.header["Content-Type"]
399 when /text\/html/: "html"
400 when /image\/(.*)/: $1
403 ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
406 ## if there's a filename, we'll treat it as an attachment.
408 [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
410 ## otherwise, it's body text
412 body = Message.convert_from m.decode, m.charset if m.body
413 text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
418 def self.convert_from body, charset
419 charset = "utf-8" if charset =~ /UTF_?8/i
421 raise MessageFormatError, "RubyMail decode returned a null body" unless body
422 return body unless charset
423 Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
424 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
425 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
426 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
431 ## parse the lines of text into chunk objects. the heuristics here
432 ## need tweaking in some nice manner. TODO: move these heuristics
433 ## into the classes themselves.
434 def text_to_chunks lines, encrypted
435 state = :text # one of :text, :quote, or :sig
439 lines.each_with_index do |line, i|
440 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
446 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
448 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
450 elsif line =~ BLOCK_QUOTE_PATTERN
451 newstate = :block_quote
455 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
465 if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
467 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
474 if chunk_lines.empty?
477 chunks << Chunk::Quote.new(chunk_lines)
483 when :block_quote, :sig
487 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
489 @snippet += " " unless @snippet.empty?
490 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
491 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
492 @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
493 @snippet_contains_encrypted_content = true if encrypted
499 when :quote, :block_quote
500 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
502 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
504 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?