7 class MessageFormatError < StandardError; end
9 ## a Message is what's threaded.
11 ## it is also where the parsing for quotes and signatures is done, but
12 ## that should be moved out to a separate class at some point (because
13 ## i would like, for example, to be able to add in a ruby-talk
14 ## specific module that would detect and link to /ruby-talk:\d+/
15 ## sequences in the text of an email. (how sweet would that be?)
17 ## this class cathces all source exceptions. if the underlying source throws
18 ## an error, it is caught and handled.
22 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
24 ## some utility methods
26 def normalize_subj s; s.gsub(RE_PATTERN, ""); end
27 def subj_is_reply? s; s =~ RE_PATTERN; end
28 def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
31 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
32 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
33 QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
34 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
36 MAX_SIG_DISTANCE = 15 # lines from the end
38 DEFAULT_SENDER = "(missing sender)"
40 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
41 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
42 :source_info, :chunks, :list_subscribe, :list_unsubscribe
44 bool_reader :dirty, :source_marked_read
46 ## if you specify a :header, will use values from that. otherwise,
47 ## will try and load the header from the source.
49 @source = opts[:source] or raise ArgumentError, "source can't be nil"
50 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
51 @snippet = opts[:snippet] || ""
52 @have_snippet = !opts[:snippet].nil?
53 @labels = [] + (opts[:labels] || [])
57 parse_header(opts[:header] || @source.load_header(@source_info))
60 def parse_header header
61 header.each { |k, v| header[k.downcase] = v }
64 if header["message-id"]
65 sanitize_message_id header["message-id"]
67 returning("sup-faked-" + Digest::MD5.hexdigest(raw_header)) do |id|
68 Redwood::log "faking message-id for message from #@from: #{id}"
74 PersonManager.person_for header["from"]
76 name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
77 Redwood::log "faking from for message #@id: #{name}"
78 PersonManager.person_for name
89 rescue ArgumentError => e
90 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
93 Redwood::log "faking date header for #{@id}"
97 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
98 @to = PersonManager.people_for header["to"]
99 @cc = PersonManager.people_for header["cc"]
100 @bcc = PersonManager.people_for header["bcc"]
101 @refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
102 @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
104 @replyto = PersonManager.person_for header["reply-to"]
106 if header["list-post"]
107 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
112 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
113 @source_marked_read = header["status"] == "RO"
114 @list_subscribe = header["list-subscribe"]
115 @list_unsubscribe = header["list-unsubscribe"]
117 private :parse_header
119 def snippet; @snippet || chunks && @snippet; end
120 def is_list_message?; !@list_address.nil?; end
121 def is_draft?; @source.is_a? DraftLoader; end
123 raise "not a draft" unless is_draft?
124 @source.fn_for_offset @source_info
127 def sanitize_message_id mid; mid.gsub(/\s/, "") end
130 index.sync_message self if @dirty
134 def has_label? t; @labels.member? t; end
136 return if @labels.member? t
141 return unless @labels.member? t
155 ## this is called when the message body needs to actually be loaded.
156 def load_from_source!
158 if @source.has_errors?
159 [Chunk::Text.new(error_message(@source.error.message.split("\n")))]
162 ## we need to re-read the header because it contains information
163 ## that we don't store in the index. actually i think it's just
164 ## the mailing list address (if any), so this is kinda overkill.
165 ## i could just store that in the index, but i think there might
166 ## be other things like that in the future, and i'd rather not
168 ## actually, it's also the differentiation between to/cc/bcc,
169 ## so i will keep this.
170 parse_header @source.load_header(@source_info)
171 message_to_chunks @source.load_message(@source_info)
172 rescue SourceError, SocketError, MessageFormatError => e
173 Redwood::log "problem getting messages from #{@source}: #{e.message}"
174 ## we need force_to_top here otherwise this window will cover
175 ## up the error message one
177 Redwood::report_broken_sources :force_to_top => true
178 [Chunk::Text.new(error_message(e.message))]
183 def error_message msg
187 ***********************************************************************
188 An error occurred while loading this message. It is possible that
189 the source has changed, or (in the case of remote sources) is down.
190 You can check the log for errors, though hopefully an error window
191 should have popped up at some point.
193 The message location was:
194 #@source##@source_info
195 ***********************************************************************
197 The error message was:
202 ## wrap any source methods that might throw sourceerrors
203 def with_source_errors_handled
206 rescue SourceError => e
207 Redwood::log "problem getting messages from #{@source}: #{e.message}"
209 Redwood::report_broken_sources :force_to_top => true
210 error_message e.message
215 with_source_errors_handled { @source.raw_header @source_info }
219 with_source_errors_handled { @source.raw_message @source_info }
222 ## much faster than raw_message
223 def each_raw_message_line &b
224 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
230 from && "#{from.name} #{from.email}",
231 to.map { |p| "#{p.name} #{p.email}" },
232 cc.map { |p| "#{p.name} #{p.email}" },
233 bcc.map { |p| "#{p.name} #{p.email}" },
234 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
235 Message.normalize_subj(subj),
236 ].flatten.compact.join " "
239 def quotable_body_lines
240 chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
243 def quotable_header_lines
244 ["From: #{@from.full_address}"] +
245 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
246 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
247 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
248 ["Date: #{@date.rfc822}",
254 ## here's where we handle decoding mime attachments. unfortunately
255 ## but unsurprisingly, the world of mime attachments is a bit of a
256 ## mess. as an empiricist, i'm basing the following behavior on
257 ## observed mail rather than on interpretations of rfcs, so probably
258 ## this will have to be tweaked.
260 ## the general behavior i want is: ignore content-disposition, at
261 ## least in so far as it suggests something being inline vs being an
262 ## attachment. (because really, that should be the recipient's
263 ## decision to make.) if a mime part is text/plain, OR if the user
264 ## decoding hook converts it, then decode it and display it
265 ## inline. for these decoded attachments, if it has associated
266 ## filename, then make it collapsable and individually saveable;
267 ## otherwise, treat it as regular body text.
269 ## everything else is just an attachment and is not displayed
272 ## so, in contrast to mutt, the user is not exposed to the workings
273 ## of the gruesome slaughterhouse and sausage factory that is a
274 ## mime-encoded message, but need only see the delicious end
277 def multipart_signed_to_chunks m
278 # Redwood::log ">> multipart SIGNED: #{m.header['Content-Type']}: #{m.body.size}"
280 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
284 payload, signature = m.body
285 if signature.multipart?
286 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
290 if payload.header.content_type == "application/pgp-signature"
291 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
295 if signature.header.content_type != "application/pgp-signature"
296 Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
300 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
303 def multipart_encrypted_to_chunks m
304 Redwood::log ">> multipart ENCRYPTED: #{m.header['Content-Type']}: #{m.body.size}"
306 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
310 control, payload = m.body
311 if control.multipart?
312 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
316 if payload.header.content_type != "application/octet-stream"
317 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
321 if control.header.content_type != "application/pgp-encrypted"
322 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
326 decryptedm, sig, notice = CryptoManager.decrypt payload
327 children = message_to_chunks(decryptedm) if decryptedm
328 [notice, sig, children].flatten.compact
331 def message_to_chunks m, sibling_types=[]
334 case m.header.content_type
335 when "multipart/signed"
336 multipart_signed_to_chunks m
337 when "multipart/encrypted"
338 multipart_encrypted_to_chunks m
342 sibling_types = m.body.map { |p| p.header.content_type }
343 chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact
347 elsif m.header.content_type == "message/rfc822"
348 payload = RMail::Parser.read(m.body)
349 from = payload.header.from.first
350 from_person = from ? PersonManager.person_for(from.format) : nil
351 [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
354 ## first, paw through the headers looking for a filename
355 if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
357 elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
360 ## haven't found one, but it's a non-text message. fake
363 ## TODO: make this less lame.
364 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
366 case m.header["Content-Type"]
367 when /text\/html/: "html"
368 when /image\/(.*)/: $1
371 ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
374 ## if there's a filename, we'll treat it as an attachment.
376 [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
378 ## otherwise, it's body text
380 body = Message.convert_from m.decode, m.charset
381 text_to_chunks((body || "").normalize_whitespace.split("\n"))
386 def self.convert_from body, charset
388 raise MessageFormatError, "RubyMail decode returned a null body" unless body
389 return body unless charset
390 Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
391 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
392 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
393 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
398 ## parse the lines of text into chunk objects. the heuristics here
399 ## need tweaking in some nice manner. TODO: move these heuristics
400 ## into the classes themselves.
401 def text_to_chunks lines
402 state = :text # one of :text, :quote, or :sig
406 lines.each_with_index do |line, i|
407 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
413 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
415 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
417 elsif line =~ BLOCK_QUOTE_PATTERN
418 newstate = :block_quote
422 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
432 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
434 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
441 if chunk_lines.empty?
444 chunks << Chunk::Quote.new(chunk_lines)
450 when :block_quote, :sig
454 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
455 @snippet += " " unless @snippet.empty?
456 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
457 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
463 when :quote, :block_quote
464 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
466 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
468 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?