]> git.cworth.org Git - sup/blob - lib/sup/message.rb
strip out non-ascii characters from message ids, and improve documentation
[sup] / lib / sup / message.rb
1 require 'time'
2 require 'iconv'
3
4 module Redwood
5
6 class MessageFormatError < StandardError; end
7
8 ## a Message is what's threaded.
9 ##
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?)
15 ##
16 ## this class cathces all source exceptions. if the underlying source throws
17 ## an error, it is caught and handled.
18
19 class Message
20   SNIPPET_LEN = 80
21   RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
22
23   ## some utility methods
24   class << self
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
28   end
29
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*--\+\+\*\*==)/
34
35   MAX_SIG_DISTANCE = 15 # lines from the end
36   DEFAULT_SUBJECT = ""
37   DEFAULT_SENDER = "(missing sender)"
38
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
42
43   bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content
44
45   ## if you specify a :header, will use values from that. otherwise,
46   ## will try and load the header from the source.
47   def initialize opts
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] || [])
54     @dirty = false
55     @encrypted = false
56     @chunks = nil
57
58     ## we need to initialize this. see comments in parse_header as to
59     ## why.
60     @refs = []
61
62     parse_header(opts[:header] || @source.load_header(@source_info))
63   end
64
65   def parse_header header
66     header.each { |k, v| header[k.downcase] = v }
67
68     fakeid = nil
69     fakename = nil
70
71     @id =
72       if header["message-id"]
73         sanitize_message_id header["message-id"]
74       else
75         fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
76       end
77     
78     @from =
79       if header["from"]
80         PersonManager.person_for header["from"]
81       else
82         fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
83         PersonManager.person_for fakename
84       end
85
86     Redwood::log "faking message-id for message from #@from: #{id}" if fakeid
87     Redwood::log "faking from for message #@id: #{fakename}" if fakename
88
89     date = header["date"]
90     @date =
91       case date
92       when Time
93         date
94       when String
95         begin
96           Time.parse date
97         rescue ArgumentError => e
98           Redwood::log "faking date header for #{@id} due to error parsing date #{header['date'].inspect}: #{e.message}"
99           Time.now
100         end
101       else
102         Redwood::log "faking date header for #{@id}"
103         Time.now
104       end
105
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"]
110
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
114     ## in here.
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 }
118
119     @replyto = PersonManager.person_for header["reply-to"]
120     @list_address =
121       if header["list-post"]
122         @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
123       else
124         nil
125       end
126
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"]
131   end
132   private :parse_header
133
134   def add_ref ref
135     @refs << ref
136     @dirty = true
137   end
138
139   def remove_ref ref
140     @dirty = true if @refs.delete ref
141   end
142
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
146   def draft_filename
147     raise "not a draft" unless is_draft?
148     @source.fn_for_offset @source_info
149   end
150
151   ## sanitize message ids by removing spaces and non-ascii characters.
152   ## also, truncate to 255 characters. all these steps are necessary
153   ## to make ferret happy. of course, we probably fuck up a couple
154   ## valid message ids as well. as long as we're consistent, this
155   ## should be fine, though.
156   ##
157   ## also, mostly the message ids that are changed by this belong to
158   ## spam email.
159   ##
160   ## an alternative would be to SHA1 or MD5 all message ids on a regular basis.
161   ## don't tempt me.
162   def sanitize_message_id mid; mid.gsub(/(\s|[^\000-\177])+/, "")[0..254] end
163
164   def save index
165     return unless @dirty
166     index.sync_message self
167     @dirty = false
168     true
169   end
170
171   def has_label? t; @labels.member? t; end
172   def add_label t
173     return if @labels.member? t
174     @labels.push t
175     @dirty = true
176   end
177   def remove_label t
178     return unless @labels.member? t
179     @labels.delete t
180     @dirty = true
181   end
182
183   def recipients
184     @to + @cc + @bcc
185   end
186
187   def labels= l
188     @labels = l
189     @dirty = true
190   end
191
192   def chunks
193     load_from_source!
194     @chunks
195   end
196
197   ## this is called when the message body needs to actually be loaded.
198   def load_from_source!
199     @chunks ||=
200       if @source.has_errors?
201         [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
202       else
203         begin
204           ## we need to re-read the header because it contains information
205           ## that we don't store in the index. actually i think it's just
206           ## the mailing list address (if any), so this is kinda overkill.
207           ## i could just store that in the index, but i think there might
208           ## be other things like that in the future, and i'd rather not
209           ## bloat the index.
210           ## actually, it's also the differentiation between to/cc/bcc,
211           ## so i will keep this.
212           parse_header @source.load_header(@source_info)
213           message_to_chunks @source.load_message(@source_info)
214         rescue SourceError, SocketError, MessageFormatError => e
215           Redwood::log "problem getting messages from #{@source}: #{e.message}"
216           ## we need force_to_top here otherwise this window will cover
217           ## up the error message one
218           @source.error ||= e
219           Redwood::report_broken_sources :force_to_top => true
220           [Chunk::Text.new(error_message(e.message).split("\n"))]
221         end
222       end
223   end
224
225   def error_message msg
226     <<EOS
227 #@snippet...
228
229 ***********************************************************************
230  An error occurred while loading this message. It is possible that
231  the source has changed, or (in the case of remote sources) is down.
232  You can check the log for errors, though hopefully an error window
233  should have popped up at some point.
234
235  The message location was:
236  #@source##@source_info
237 ***********************************************************************
238
239 The error message was:
240   #{msg}
241 EOS
242   end
243
244   ## wrap any source methods that might throw sourceerrors
245   def with_source_errors_handled
246     begin
247       yield
248     rescue SourceError => e
249       Redwood::log "problem getting messages from #{@source}: #{e.message}"
250       @source.error ||= e
251       Redwood::report_broken_sources :force_to_top => true
252       error_message e.message
253     end
254   end
255
256   def raw_header
257     with_source_errors_handled { @source.raw_header @source_info }
258   end
259
260   def raw_message
261     with_source_errors_handled { @source.raw_message @source_info }
262   end
263
264   ## much faster than raw_message
265   def each_raw_message_line &b
266     with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
267   end
268
269   ## returns all the content from a message that will be indexed
270   def indexable_content
271     load_from_source!
272     [
273       from && from.indexable_content,
274       to.map { |p| p.indexable_content },
275       cc.map { |p| p.indexable_content },
276       bcc.map { |p| p.indexable_content },
277       chunks.select { |c| c.is_a? Chunk::Text }.map { |c| c.lines },
278       Message.normalize_subj(subj),
279     ].flatten.compact.join " "
280   end
281
282   def quotable_body_lines
283     chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
284   end
285
286   def quotable_header_lines
287     ["From: #{@from.full_address}"] +
288       (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
289       (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
290       (@bcc.empty? ? [] : ["Bcc: " + @bcc.map { |p| p.full_address }.join(", ")]) +
291       ["Date: #{@date.rfc822}",
292        "Subject: #{@subj}"]
293   end
294
295 private
296
297   ## here's where we handle decoding mime attachments. unfortunately
298   ## but unsurprisingly, the world of mime attachments is a bit of a
299   ## mess. as an empiricist, i'm basing the following behavior on
300   ## observed mail rather than on interpretations of rfcs, so probably
301   ## this will have to be tweaked.
302   ##
303   ## the general behavior i want is: ignore content-disposition, at
304   ## least in so far as it suggests something being inline vs being an
305   ## attachment. (because really, that should be the recipient's
306   ## decision to make.) if a mime part is text/plain, OR if the user
307   ## decoding hook converts it, then decode it and display it
308   ## inline. for these decoded attachments, if it has associated
309   ## filename, then make it collapsable and individually saveable;
310   ## otherwise, treat it as regular body text.
311   ##
312   ## everything else is just an attachment and is not displayed
313   ## inline.
314   ##
315   ## so, in contrast to mutt, the user is not exposed to the workings
316   ## of the gruesome slaughterhouse and sausage factory that is a
317   ## mime-encoded message, but need only see the delicious end
318   ## product.
319
320   def multipart_signed_to_chunks m
321     if m.body.size != 2
322       Redwood::log "warning: multipart/signed with #{m.body.size} parts (expecting 2)"
323       return
324     end
325
326     payload, signature = m.body
327     if signature.multipart?
328       Redwood::log "warning: multipart/signed with payload multipart #{payload.multipart?} and signature multipart #{signature.multipart?}"
329       return
330     end
331
332     ## this probably will never happen
333     if payload.header.content_type == "application/pgp-signature"
334       Redwood::log "warning: multipart/signed with payload content type #{payload.header.content_type}"
335       return
336     end
337
338     if signature.header.content_type != "application/pgp-signature"
339       ## unknown signature type; just ignore.
340       #Redwood::log "warning: multipart/signed with signature content type #{signature.header.content_type}"
341       return
342     end
343
344     [CryptoManager.verify(payload, signature), message_to_chunks(payload)].flatten.compact
345   end
346
347   def multipart_encrypted_to_chunks m
348     if m.body.size != 2
349       Redwood::log "warning: multipart/encrypted with #{m.body.size} parts (expecting 2)"
350       return
351     end
352
353     control, payload = m.body
354     if control.multipart?
355       Redwood::log "warning: multipart/encrypted with control multipart #{control.multipart?} and payload multipart #{payload.multipart?}"
356       return
357     end
358
359     if payload.header.content_type != "application/octet-stream"
360       Redwood::log "warning: multipart/encrypted with payload content type #{payload.header.content_type}"
361       return
362     end
363
364     if control.header.content_type != "application/pgp-encrypted"
365       Redwood::log "warning: multipart/encrypted with control content type #{signature.header.content_type}"
366       return
367     end
368
369     decryptedm, sig, notice = CryptoManager.decrypt payload
370     children = message_to_chunks(decryptedm, true) if decryptedm
371     [notice, sig, children].flatten.compact
372   end
373
374   def message_to_chunks m, encrypted=false, sibling_types=[]
375     if m.multipart?
376       chunks =
377         case m.header.content_type
378         when "multipart/signed"
379           multipart_signed_to_chunks m
380         when "multipart/encrypted"
381           multipart_encrypted_to_chunks m
382         end
383
384       unless chunks
385         sibling_types = m.body.map { |p| p.header.content_type }
386         chunks = m.body.map { |p| message_to_chunks p, encrypted, sibling_types }.flatten.compact
387       end
388
389       chunks
390     elsif m.header.content_type == "message/rfc822"
391       payload = RMail::Parser.read(m.body)
392       from = payload.header.from.first
393       from_person = from ? PersonManager.person_for(from.format) : nil
394       [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
395     else
396       filename =
397         ## first, paw through the headers looking for a filename
398         if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
399           $1
400         elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
401           $1
402
403         ## haven't found one, but it's a non-text message. fake
404         ## it.
405         ##
406         ## TODO: make this less lame.
407         elsif m.header["Content-Type"] && m.header["Content-Type"] !~ /^text\/plain/
408           extension =
409             case m.header["Content-Type"]
410             when /text\/html/: "html"
411             when /image\/(.*)/: $1
412             end
413
414           ["sup-attachment-#{Time.now.to_i}-#{rand 10000}", extension].join(".")
415         end
416
417       ## if there's a filename, we'll treat it as an attachment.
418       if filename
419         [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
420
421       ## otherwise, it's body text
422       else
423         body = Message.convert_from m.decode, m.charset if m.body
424         text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
425       end
426     end
427   end
428
429   def self.convert_from body, charset
430     charset = "utf-8" if charset =~ /UTF_?8/i
431     begin
432       raise MessageFormatError, "RubyMail decode returned a null body" unless body
433       return body unless charset
434       Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
435     rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
436       Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
437       File.open(File.join(BASE_DIR,"unable-to-decode.txt"), "w") { |f| f.write body }
438       body
439     end
440   end
441
442   ## parse the lines of text into chunk objects.  the heuristics here
443   ## need tweaking in some nice manner. TODO: move these heuristics
444   ## into the classes themselves.
445   def text_to_chunks lines, encrypted
446     state = :text # one of :text, :quote, or :sig
447     chunks = []
448     chunk_lines = []
449
450     lines.each_with_index do |line, i|
451       nextline = lines[(i + 1) ... lines.length].find { |l| l !~ /^\s*$/ } # skip blank lines
452
453       case state
454       when :text
455         newstate = nil
456
457         if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
458           newstate = :quote
459         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
460           newstate = :sig
461         elsif line =~ BLOCK_QUOTE_PATTERN
462           newstate = :block_quote
463         end
464
465         if newstate
466           chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
467           chunk_lines = [line]
468           state = newstate
469         else
470           chunk_lines << line
471         end
472
473       when :quote
474         newstate = nil
475
476         if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
477           chunk_lines << line
478         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
479           newstate = :sig
480         else
481           newstate = :text
482         end
483
484         if newstate
485           if chunk_lines.empty?
486             # nothing
487           else
488             chunks << Chunk::Quote.new(chunk_lines)
489           end
490           chunk_lines = [line]
491           state = newstate
492         end
493
494       when :block_quote, :sig
495         chunk_lines << line
496       end
497
498       if !@have_snippet && state == :text && (@snippet.nil? || @snippet.length < SNIPPET_LEN) && line !~ /[=\*#_-]{3,}/ && line !~ /^\s*$/
499         @snippet ||= ""
500         @snippet += " " unless @snippet.empty?
501         @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
502         @snippet = @snippet[0 ... SNIPPET_LEN].chomp
503         @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
504         @snippet_contains_encrypted_content = true if encrypted
505       end
506     end
507
508     ## final object
509     case state
510     when :quote, :block_quote
511       chunks << Chunk::Quote.new(chunk_lines) unless chunk_lines.empty?
512     when :text
513       chunks << Chunk::Text.new(chunk_lines) unless chunk_lines.empty?
514     when :sig
515       chunks << Chunk::Signature.new(chunk_lines) unless chunk_lines.empty?
516     end
517     chunks
518   end
519 end
520
521 end