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?)
18 RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
20 ## some utility methods
22 def normalize_subj s; s.gsub(RE_PATTERN, ""); end
23 def subj_is_reply? s; s =~ RE_PATTERN; end
24 def reify_subj s; subj_is_reply?(s) ? s : "Re: " + s; end
27 QUOTE_PATTERN = /^\s{0,4}[>|\}]/
28 BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
29 QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
30 SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
32 MAX_SIG_DISTANCE = 15 # lines from the end
34 DEFAULT_SENDER = "(missing sender)"
36 attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
37 :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
40 bool_reader :dirty, :source_marked_read
42 ## if you specify a :header, will use values from that. otherwise,
43 ## will try and load the header from the source.
45 @source = opts[:source] or raise ArgumentError, "source can't be nil"
46 @source_info = opts[:source_info] or raise ArgumentError, "source_info can't be nil"
47 @snippet = opts[:snippet] || ""
48 @have_snippet = !opts[:snippet].nil?
49 @labels = [] + (opts[:labels] || [])
53 parse_header(opts[:header] || @source.load_header(@source_info))
56 def parse_header header
57 header.each { |k, v| header[k.downcase] = v }
59 @from = PersonManager.person_for header["from"]
62 if header["message-id"]
63 sanitize_message_id header["message-id"]
65 "sup-faked-" + Digest::MD5.hexdigest(raw_header)
66 Redwood::log "faking message-id for message from #@from: #@id"
77 rescue ArgumentError => e
78 raise MessageFormatError, "unparsable date #{header['date']}: #{e.message}"
81 Redwood::log "faking date header for #{@id}"
85 @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
86 @to = PersonManager.people_for header["to"]
87 @cc = PersonManager.people_for header["cc"]
88 @bcc = PersonManager.people_for header["bcc"]
89 @refs = (header["references"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
90 @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
92 @replyto = PersonManager.person_for header["reply-to"]
94 if header["list-post"]
95 @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
100 @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
101 @source_marked_read = header["status"] == "RO"
103 private :parse_header
105 def snippet; @snippet || chunks && @snippet; end
106 def is_list_message?; !@list_address.nil?; end
107 def is_draft?; @source.is_a? DraftLoader; end
109 raise "not a draft" unless is_draft?
110 @source.fn_for_offset @source_info
113 def sanitize_message_id mid; mid.gsub(/\s/, "") end
116 index.sync_message self if @dirty
120 def has_label? t; @labels.member? t; end
122 return if @labels.member? t
127 return unless @labels.member? t
141 ## this is called when the message body needs to actually be loaded.
142 def load_from_source!
144 if @source.has_errors?
145 [Chunk::Text.new(error_message(@source.error.message.split("\n")))]
148 ## we need to re-read the header because it contains information
149 ## that we don't store in the index. actually i think it's just
150 ## the mailing list address (if any), so this is kinda overkill.
151 ## i could just store that in the index, but i think there might
152 ## be other things like that in the future, and i'd rather not
154 ## actually, it's also the differentiation between to/cc/bcc,
155 ## so i will keep this.
156 parse_header @source.load_header(@source_info)
157 message_to_chunks @source.load_message(@source_info)
158 rescue SourceError, SocketError, MessageFormatError => e
159 Redwood::log "problem getting messages from #{@source}: #{e.message}"
160 ## we need force_to_top here otherwise this window will cover
161 ## up the error message one
162 Redwood::report_broken_sources :force_to_top => true
163 [Chunk::Text.new(error_message(e.message))]
168 def error_message msg
172 ***********************************************************************
173 An error occurred while loading this message. It is possible that
174 the source has changed, or (in the case of remote sources) is down.
175 You can check the log for errors, though hopefully an error window
176 should have popped up at some point.
178 The message location was:
179 #@source##@source_info
180 ***********************************************************************
182 The error message was:
187 def with_source_errors_handled
190 rescue SourceError => e
191 Redwood::log "problem getting messages from #{@source}: #{e.message}"
192 error_message e.message
197 with_source_errors_handled { @source.raw_header @source_info }
201 with_source_errors_handled { @source.raw_message @source_info }
204 ## much faster than raw_message
205 def each_raw_message_line &b
206 with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
212 from && "#{from.name} #{from.email}",
213 to.map { |p| "#{p.name} #{p.email}" },
214 cc.map { |p| "#{p.name} #{p.email}" },
215 bcc.map { |p| "#{p.name} #{p.email}" },
216 chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
217 Message.normalize_subj(subj),
218 ].flatten.compact.join " "
222 chunks.find_all { |c| c.is_a?(Chunk::Text) || c.is_a?(Chunk::Quote) }.map { |c| c.lines }.flatten
225 def basic_header_lines
226 ["From: #{@from.full_address}"] +
227 (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
228 (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
229 (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
230 ["Date: #{@date.rfc822}",
236 ## here's where we handle decoding mime attachments. unfortunately
237 ## but unsurprisingly, the world of mime attachments is a bit of a
238 ## mess. as an empiricist, i'm basing the following behavior on
239 ## observed mail rather than on interpretations of rfcs, so probably
240 ## this will have to be tweaked.
242 ## the general behavior i want is: ignore content-disposition, at
243 ## least in so far as it suggests something being inline vs being an
244 ## attachment. (because really, that should be the recipient's
245 ## decision to make.) if a mime part is text/plain, OR if the user
246 ## decoding hook converts it, then decode it and display it
247 ## inline. for these decoded attachments, if it has associated
248 ## filename, then make it collapsable and individually saveable;
249 ## otherwise, treat it as regular body text.
251 ## everything else is just an attachment and is not displayed
254 ## so, in contrast to mutt, the user is not exposed to the workings
255 ## of the gruesome slaughterhouse and sausage factory that is a
256 ## mime-encoded message, but need only see the delicious end
259 def multipart_signed_to_chunks m
260 # Redwood::log ">> multipart SIGNED: #{m.header['Content-Type']}: #{m.body.size}"
262 Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
266 payload, signature = m.body
267 if signature.multipart?
268 Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
272 if payload.header.content_type == "application/pgp-signature"
273 Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
277 if signature.header.content_type != "application/pgp-signature"
278 Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
282 [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
285 def multipart_encrypted_to_chunks m
286 Redwood::log ">> multipart ENCRYPTED: #{m.header['Content-Type']}: #{m.body.size}"
288 Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
292 control, payload = m.body
293 if control.multipart?
294 Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
298 if payload.header.content_type != "application/octet-stream"
299 Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
303 if control.header.content_type != "application/pgp-encrypted"
304 Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
308 decryptedm, sig, notice = CryptoManager.decrypt payload
309 children = message_to_chunks(decryptedm) if decryptedm
310 [notice, sig, children].flatten.compact
313 def message_to_chunks m, sibling_types=[]
316 case m.header.content_type
317 when "multipart/signed"
318 multipart_signed_to_chunks m
319 when "multipart/encrypted"
320 multipart_encrypted_to_chunks m
324 sibling_types = m.body.map { |p| p.header.content_type }
325 chunks = m.body.map { |p| message_to_chunks p, sibling_types }.flatten.compact
329 elsif m.header.content_type == "message/rfc822"
330 payload = RMail::Parser.read(m.body)
331 from = payload.header.from.first
332 from_person = from ? PersonManager.person_for(from.format) : nil
333 [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
336 ## first, paw through the headers looking for a filename
337 if m.header["Content-Disposition"] &&
338 m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
340 elsif m.header["Content-Type"] &&
341 m.header["Content-Type"] =~ /name=(.*?)(;|$)/
344 ## haven't found one, but it's a non-text message. fake
346 elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
347 "sup-attachment-#{Time.now.to_i}-#{rand 10000}"
350 ## if there's a filename, we'll treat it as an attachment.
352 [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
354 ## otherwise, it's body text
356 body = Message.convert_from m.decode, m.charset
357 text_to_chunks body.normalize_whitespace.split("\n")
362 def self.convert_from body, charset
363 return body unless charset
366 Iconv.iconv($encoding, charset, body).join
367 rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
368 Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
369 File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
374 ## parse the lines of text into chunk objects. the heuristics here
375 ## need tweaking in some nice manner. TODO: move these heuristics
376 ## into the classes themselves.
377 def text_to_chunks lines
378 state = :text # one of :text, :quote, or :sig
382 lines.each_with_index do |line, i|
383 nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
389 if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
391 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
393 elsif line =~ BLOCK_QUOTE_PATTERN
394 newstate = :block_quote
398 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
408 if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
410 elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
417 if chunk_lines.empty?
420 chunks << Chunk::Quote.new(chunk_lines)
426 when :block_quote, :sig
430 if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
431 @snippet += " " unless @snippet.empty?
432 @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
433 @snippet = @snippet[0 ... SNIPPET_LEN].chomp
439 when :quote, :block_quote
440 chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
442 chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
444 chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?