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 parse_header(opts[:header] || @source.load_header(@source_info))
61 def parse_header header
62 header.each { |k, v| header[k.downcase] = v }
68 if header["message-id"]
69 sanitize_message_id header["message-id"]
71 fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
76 PersonManager.person_for header["from"]
78 fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
79 PersonManager.person_for fakename
82 Redwood::log "faking message-id for message from #@from: #{id}" if fakeid
83 Redwood::log "faking from for message #@id: #{fakename}" if fakename
93 rescue ArgumentError => e
94 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
97 Redwood::log "faking date header for #{@id}"
101 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
102 @to = PersonManager.people_for header["to"]
103 @cc = PersonManager.people_for header["cc"]
104 @bcc = PersonManager.people_for header["bcc"]
105 @refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
106 @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
108 @replyto = PersonManager.person_for header["reply-to"]
110 if header["list-post"]
111 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
116 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
117 @source_marked_read = header["status"] == "RO"
118 @list_subscribe = header["list-subscribe"]
119 @list_unsubscribe = header["list-unsubscribe"]
121 private :parse_header
123 def snippet; @snippet || (chunks && @snippet); end
124 def is_list_message?; !@list_address.nil?; end
125 def is_draft?; @source.is_a? DraftLoader; end
127 raise "not a draft" unless is_draft?
128 @source.fn_for_offset @source_info
131 def sanitize_message_id mid; mid.gsub(/\s/, "") end
134 index.sync_message self if @dirty
138 def has_label? t; @labels.member? t; end
140 return if @labels.member? t
145 return unless @labels.member? t
164 ## this is called when the message body needs to actually be loaded.
165 def load_from_source!
167 if @source.has_errors?
168 [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
171 ## we need to re-read the header because it contains information
172 ## that we don't store in the index. actually i think it's just
173 ## the mailing list address (if any), so this is kinda overkill.
174 ## i could just store that in the index, but i think there might
175 ## be other things like that in the future, and i'd rather not
177 ## actually, it's also the differentiation between to/cc/bcc,
178 ## so i will keep this.
179 parse_header @source.load_header(@source_info)
180 message_to_chunks @source.load_message(@source_info)
181 rescue SourceError, SocketError, MessageFormatError => e
182 Redwood::log "problem getting messages from #{@source}: #{e.message}"
183 ## we need force_to_top here otherwise this window will cover
184 ## up the error message one
186 Redwood::report_broken_sources :force_to_top => true
187 [Chunk::Text.new(error_message(e.message).split("\n"))]
192 def error_message msg
196 ***********************************************************************
197 An error occurred while loading this message. It is possible that
198 the source has changed, or (in the case of remote sources) is down.
199 You can check the log for errors, though hopefully an error window
200 should have popped up at some point.
202 The message location was:
203 #@source##@source_info
204 ***********************************************************************
206 The error message was:
211 ## wrap any source methods that might throw sourceerrors
212 def with_source_errors_handled
215 rescue SourceError => e
216 Redwood::log "problem getting messages from #{@source}: #{e.message}"
218 Redwood::report_broken_sources :force_to_top => true
219 error_message e.message
224 with_source_errors_handled { @source.raw_header @source_info }
228 with_source_errors_handled { @source.raw_message @source_info }
231 ## much faster than raw_message
232 def each_raw_message_line &b
233 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
239 from && "#{from.name} #{from.email}",
240 to.map { |p| "#{p.name} #{p.email}" },
241 cc.map { |p| "#{p.name} #{p.email}" },
242 bcc.map { |p| "#{p.name} #{p.email}" },
243 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
244 Message.normalize_subj(subj),
245 ].flatten.compact.join " "
248 def quotable_body_lines
249 chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
252 def quotable_header_lines
253 ["From: #{@from.full_address}"] +
254 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
255 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
256 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
257 ["Date: #{@date.rfc822}",
263 ## here's where we handle decoding mime attachments. unfortunately
264 ## but unsurprisingly, the world of mime attachments is a bit of a
265 ## mess. as an empiricist, i'm basing the following behavior on
266 ## observed mail rather than on interpretations of rfcs, so probably
267 ## this will have to be tweaked.
269 ## the general behavior i want is: ignore content-disposition, at
270 ## least in so far as it suggests something being inline vs being an
271 ## attachment. (because really, that should be the recipient's
272 ## decision to make.) if a mime part is text/plain, OR if the user
273 ## decoding hook converts it, then decode it and display it
274 ## inline. for these decoded attachments, if it has associated
275 ## filename, then make it collapsable and individually saveable;
276 ## otherwise, treat it as regular body text.
278 ## everything else is just an attachment and is not displayed
281 ## so, in contrast to mutt, the user is not exposed to the workings
282 ## of the gruesome slaughterhouse and sausage factory that is a
283 ## mime-encoded message, but need only see the delicious end
286 def multipart_signed_to_chunks m
288 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
292 payload, signature = m.body
293 if signature.multipart?
294 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
298 ## this probably will never happen
299 if payload.header.content_type == "application/pgp-signature"
300 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
304 if signature.header.content_type != "application/pgp-signature"
305 ## unknown signature type; just ignore.
306 #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
310 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
313 def multipart_encrypted_to_chunks m
315 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
319 control, payload = m.body
320 if control.multipart?
321 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
325 if payload.header.content_type != "application/octet-stream"
326 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
330 if control.header.content_type != "application/pgp-encrypted"
331 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
335 decryptedm, sig, notice = CryptoManager.decrypt payload
336 children = message_to_chunks(decryptedm, true) if decryptedm
337 [notice, sig, children].flatten.compact
340 def message_to_chunks m, encrypted=false, sibling_types=[]
343 case m.header.content_type
344 when "multipart/signed"
345 multipart_signed_to_chunks m
346 when "multipart/encrypted"
347 multipart_encrypted_to_chunks m
351 sibling_types = m.body.map { |p| p.header.content_type }
352 chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
356 elsif m.header.content_type == "message/rfc822"
357 payload = RMail::Parser.read(m.body)
358 from = payload.header.from.first
359 from_person = from ? PersonManager.person_for(from.format) : nil
360 [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
363 ## first, paw through the headers looking for a filename
364 if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
366 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
369 ## haven't found one, but it's a non-text message. fake
372 ## TODO: make this less lame.
373 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
375 case m.header["Content-Type"]
376 when /text\/html/: "html"
377 when /image\/(.*)/: $1
380 ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
383 ## if there's a filename, we'll treat it as an attachment.
385 [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
387 ## otherwise, it's body text
389 body = Message.convert_from m.decode, m.charset if m.body
390 text_to_chunks (body || "").normalize_whitespace.split("\n"), encrypted
395 def self.convert_from body, charset
396 charset = "utf-8" if charset =~ /UTF_?8/i
398 raise MessageFormatError, "RubyMail decode returned a null body" unless body
399 return body unless charset
400 Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
401 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
402 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
403 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
408 ## parse the lines of text into chunk objects. the heuristics here
409 ## need tweaking in some nice manner. TODO: move these heuristics
410 ## into the classes themselves.
411 def text_to_chunks lines, encrypted
412 state = :text # one of :text, :quote, or :sig
416 lines.each_with_index do |line, i|
417 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
423 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
425 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
427 elsif line =~ BLOCK_QUOTE_PATTERN
428 newstate = :block_quote
432 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
442 if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
444 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
451 if chunk_lines.empty?
454 chunks << Chunk::Quote.new(chunk_lines)
460 when :block_quote, :sig
464 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
466 @snippet += " " unless @snippet.empty?
467 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
468 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
469 @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
470 @snippet_contains_encrypted_content = true if encrypted
476 when :quote, :block_quote
477 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
479 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
481 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?