sup 0.11 → 0.12

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of sup might be problematic. Click here for more details.

@@ -9,9 +9,6 @@ module Redwood
9
9
  ## i would like, for example, to be able to add in a ruby-talk
10
10
  ## specific module that would detect and link to /ruby-talk:\d+/
11
11
  ## sequences in the text of an email. (how sweet would that be?)
12
- ##
13
- ## this class catches all source exceptions. if the underlying source
14
- ## throws an error, it is caught and handled.
15
12
 
16
13
  class Message
17
14
  SNIPPET_LEN = 80
@@ -26,24 +23,32 @@ class Message
26
23
 
27
24
  QUOTE_PATTERN = /^\s{0,4}[>|\}]/
28
25
  BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
29
- SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
26
+ SIG_PATTERN = /(^(- )*-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
27
+
28
+ GPG_SIGNED_START = "-----BEGIN PGP SIGNED MESSAGE-----"
29
+ GPG_SIGNED_END = "-----END PGP SIGNED MESSAGE-----"
30
+ GPG_START = "-----BEGIN PGP MESSAGE-----"
31
+ GPG_END = "-----END PGP MESSAGE-----"
32
+ GPG_SIG_START = "-----BEGIN PGP SIGNATURE-----"
33
+ GPG_SIG_END = "-----END PGP SIGNATURE-----"
30
34
 
31
35
  MAX_SIG_DISTANCE = 15 # lines from the end
32
36
  DEFAULT_SUBJECT = ""
33
37
  DEFAULT_SENDER = "(missing sender)"
34
38
  MAX_HEADER_VALUE_SIZE = 4096
35
39
 
36
- attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
40
+ attr_reader :id, :date, :from, :subj, :refs, :replytos, :to,
37
41
  :cc, :bcc, :labels, :attachments, :list_address, :recipient_email, :replyto,
38
- :source_info, :list_subscribe, :list_unsubscribe
42
+ :list_subscribe, :list_unsubscribe
39
43
 
40
44
  bool_reader :dirty, :source_marked_read, :snippet_contains_encrypted_content
41
45
 
46
+ attr_accessor :locations
47
+
42
48
  ## if you specify a :header, will use values from that. otherwise,
43
49
  ## will try and load the header from the source.
44
50
  def initialize opts
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"
51
+ @locations = opts[:locations] or raise ArgumentError, "locations can't be nil"
47
52
  @snippet = opts[:snippet]
48
53
  @snippet_contains_encrypted_content = false
49
54
  @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?)
@@ -70,14 +75,15 @@ class Message
70
75
  def parse_header encoded_header
71
76
  header = SavingHash.new { |k| decode_header_field encoded_header[k] }
72
77
 
73
- @id = if header["message-id"]
78
+ @id = ''
79
+ if header["message-id"]
74
80
  mid = header["message-id"] =~ /<(.+?)>/ ? $1 : header["message-id"]
75
- sanitize_message_id mid
76
- else
77
- id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
78
- from = header["from"]
81
+ @id = sanitize_message_id mid
82
+ end
83
+ if (not @id.include? '@') || @id.length < 6
84
+ @id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
85
+ #from = header["from"]
79
86
  #debug "faking non-existent message-id for message from #{from}: #{id}"
80
- id
81
87
  end
82
88
 
83
89
  @from = Person.from_address(if header["from"]
@@ -170,10 +176,10 @@ class Message
170
176
 
171
177
  attr_reader :snippet
172
178
  def is_list_message?; !@list_address.nil?; end
173
- def is_draft?; @source.is_a? DraftLoader; end
179
+ def is_draft?; @labels.member? :draft; end
174
180
  def draft_filename
175
181
  raise "not a draft" unless is_draft?
176
- @source.fn_for_offset @source_info
182
+ source.fn_for_offset source_info
177
183
  end
178
184
 
179
185
  ## sanitize message ids by removing spaces and non-ascii characters.
@@ -224,77 +230,59 @@ class Message
224
230
  @chunks
225
231
  end
226
232
 
233
+ def location
234
+ @locations.find { |x| x.valid? } || raise(OutOfSyncSourceError.new)
235
+ end
236
+
237
+ def source
238
+ location.source
239
+ end
240
+
241
+ def source_info
242
+ location.info
243
+ end
244
+
227
245
  ## this is called when the message body needs to actually be loaded.
228
246
  def load_from_source!
229
247
  @chunks ||=
230
- if @source.respond_to?(:has_errors?) && @source.has_errors?
231
- [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
232
- else
233
- begin
234
- ## we need to re-read the header because it contains information
235
- ## that we don't store in the index. actually i think it's just
236
- ## the mailing list address (if any), so this is kinda overkill.
237
- ## i could just store that in the index, but i think there might
238
- ## be other things like that in the future, and i'd rather not
239
- ## bloat the index.
240
- ## actually, it's also the differentiation between to/cc/bcc,
241
- ## so i will keep this.
242
- rmsg = @source.load_message(@source_info)
243
- parse_header rmsg.header
244
- message_to_chunks rmsg
245
- rescue SourceError, SocketError => e
246
- warn "problem getting messages from #{@source}: #{e.message}"
247
- ## we need force_to_top here otherwise this window will cover
248
- ## up the error message one
249
- @source.error ||= e
250
- Redwood::report_broken_sources :force_to_top => true
251
- [Chunk::Text.new(error_message(e.message).split("\n"))]
252
- end
248
+ begin
249
+ ## we need to re-read the header because it contains information
250
+ ## that we don't store in the index. actually i think it's just
251
+ ## the mailing list address (if any), so this is kinda overkill.
252
+ ## i could just store that in the index, but i think there might
253
+ ## be other things like that in the future, and i'd rather not
254
+ ## bloat the index.
255
+ ## actually, it's also the differentiation between to/cc/bcc,
256
+ ## so i will keep this.
257
+ rmsg = location.parsed_message
258
+ parse_header rmsg.header
259
+ message_to_chunks rmsg
260
+ rescue SourceError, SocketError, RMail::EncodingUnsupportedError => e
261
+ warn "problem reading message #{id}"
262
+ [Chunk::Text.new(error_message.split("\n"))]
253
263
  end
254
264
  end
255
265
 
256
- def error_message msg
266
+ def error_message
257
267
  <<EOS
258
268
  #@snippet...
259
269
 
260
270
  ***********************************************************************
261
- An error occurred while loading this message. It is possible that
262
- the source has changed, or (in the case of remote sources) is down.
263
- You can check the log for errors, though hopefully an error window
264
- should have popped up at some point.
265
-
266
- The message location was:
267
- #@source##@source_info
271
+ An error occurred while loading this message.
268
272
  ***********************************************************************
269
-
270
- The error message was:
271
- #{msg}
272
273
  EOS
273
274
  end
274
275
 
275
- ## wrap any source methods that might throw sourceerrors
276
- def with_source_errors_handled
277
- begin
278
- yield
279
- rescue SourceError => e
280
- warn "problem getting messages from #{@source}: #{e.message}"
281
- @source.error ||= e
282
- Redwood::report_broken_sources :force_to_top => true
283
- error_message e.message
284
- end
285
- end
286
-
287
276
  def raw_header
288
- with_source_errors_handled { @source.raw_header @source_info }
277
+ location.raw_header
289
278
  end
290
279
 
291
280
  def raw_message
292
- with_source_errors_handled { @source.raw_message @source_info }
281
+ location.raw_message
293
282
  end
294
283
 
295
- ## much faster than raw_message
296
284
  def each_raw_message_line &b
297
- with_source_errors_handled { @source.each_raw_message_line(@source_info, &b) }
285
+ location.each_raw_message_line &b
298
286
  end
299
287
 
300
288
  ## returns all the content from a message that will be indexed
@@ -336,7 +324,7 @@ EOS
336
324
  end
337
325
 
338
326
  def self.build_from_source source, source_info
339
- m = Message.new :source => source, :source_info => source_info
327
+ m = Message.new :locations => [Location.new(source, source_info)]
340
328
  m.load_from_source!
341
329
  m
342
330
  end
@@ -442,8 +430,21 @@ private
442
430
 
443
431
  chunks
444
432
  elsif m.header.content_type && m.header.content_type.downcase == "message/rfc822"
433
+ encoding = m.header["Content-Transfer-Encoding"]
445
434
  if m.body
446
- payload = RMail::Parser.read(m.body)
435
+ body =
436
+ case encoding
437
+ when "base64"
438
+ m.body.unpack("m")[0]
439
+ when "quoted-printable"
440
+ m.body.unpack("M")[0]
441
+ when "7bit", "8bit", nil
442
+ m.body
443
+ else
444
+ raise RMail::EncodingUnsupportedError, encoding.inspect
445
+ end
446
+ body = body.normalize_whitespace
447
+ payload = RMail::Parser.read(body)
447
448
  from = payload.header.from.first ? payload.header.from.first.format : ""
448
449
  to = payload.header.to.map { |p| p.format }.join(", ")
449
450
  cc = payload.header.cc.map { |p| p.format }.join(", ")
@@ -474,10 +475,12 @@ private
474
475
  end
475
476
  else
476
477
  filename =
477
- ## first, paw through the headers looking for a filename
478
- if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
478
+ ## first, paw through the headers looking for a filename.
479
+ ## RFC 2183 (Content-Disposition) specifies that disposition-parms are
480
+ ## separated by ";". So, we match everything up to " and ; (if present).
481
+ if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|\z)/m
479
482
  $1
480
- elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/i
483
+ elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|\z)/im
481
484
  $1
482
485
 
483
486
  ## haven't found one, but it's a non-text message. fake
@@ -508,12 +511,75 @@ private
508
511
 
509
512
  ## otherwise, it's body text
510
513
  else
511
- ## if there's no charset, use the current encoding as the charset.
512
- ## this ensures that the body is normalized to avoid non-displayable
513
- ## characters
514
- body = Iconv.easy_decode($encoding, m.charset || $encoding, m.decode) if m.body
515
- text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
514
+ ## Decode the body, charset conversion will follow either in
515
+ ## inline_gpg_to_chunks (for inline GPG signed messages) or
516
+ ## a few lines below (messages without inline GPG)
517
+ body = m.body ? m.decode : ""
518
+
519
+ ## Check for inline-PGP
520
+ chunks = inline_gpg_to_chunks body, $encoding, (m.charset || $encoding)
521
+ return chunks if chunks
522
+
523
+ if m.body
524
+ ## if there's no charset, use the current encoding as the charset.
525
+ ## this ensures that the body is normalized to avoid non-displayable
526
+ ## characters
527
+ body = Iconv.easy_decode($encoding, m.charset || $encoding, m.decode)
528
+ else
529
+ body = ""
530
+ end
531
+
532
+ text_to_chunks(body.normalize_whitespace.split("\n"), encrypted)
533
+ end
534
+ end
535
+ end
536
+
537
+ ## looks for gpg signed (but not encrypted) inline messages inside the
538
+ ## message body (there is no extra header for inline GPG) or for encrypted
539
+ ## (and possible signed) inline GPG messages
540
+ def inline_gpg_to_chunks body, encoding_to, encoding_from
541
+ lines = body.split("\n")
542
+ gpg = lines.between(GPG_SIGNED_START, GPG_SIGNED_END)
543
+ if !gpg.empty?
544
+ msg = RMail::Message.new
545
+ msg.body = gpg.join("\n")
546
+
547
+ body = Iconv.easy_decode(encoding_to, encoding_from, body)
548
+ lines = body.split("\n")
549
+ sig = lines.between(GPG_SIGNED_START, GPG_SIG_START)
550
+ startidx = lines.index(GPG_SIGNED_START)
551
+ endidx = lines.index(GPG_SIG_END)
552
+ before = startidx != 0 ? lines[0 .. startidx-1] : []
553
+ after = endidx ? lines[endidx+1 .. lines.size] : []
554
+
555
+ payload = RMail::Message.new
556
+ payload.body = sig[1, sig.size-2].join("\n")
557
+ return [text_to_chunks(before, false),
558
+ CryptoManager.verify(nil, msg, false),
559
+ message_to_chunks(payload),
560
+ text_to_chunks(after, false)].flatten.compact
561
+ end
562
+
563
+ gpg = lines.between(GPG_START, GPG_END)
564
+ # between does not check if GPG_END actually exists
565
+ if !gpg.empty? && !lines.index(GPG_END).nil?
566
+ msg = RMail::Message.new
567
+ msg.body = gpg.join("\n")
568
+
569
+ startidx = lines.index(GPG_START)
570
+ before = startidx != 0 ? lines[0 .. startidx-1] : []
571
+ after = lines[lines.index(GPG_END)+1 .. lines.size]
572
+
573
+ notice, sig, decryptedm = CryptoManager.decrypt msg, true
574
+ chunks = if decryptedm # managed to decrypt
575
+ children = message_to_chunks(decryptedm, true)
576
+ [notice, sig].compact + children
577
+ else
578
+ [notice]
516
579
  end
580
+ return [text_to_chunks(before, false),
581
+ chunks,
582
+ text_to_chunks(after, false)].flatten.compact
517
583
  end
518
584
  end
519
585
 
@@ -581,7 +647,9 @@ private
581
647
  @snippet ||= ""
582
648
  @snippet += " " unless @snippet.empty?
583
649
  @snippet += line.gsub(/^\s+/, "").gsub(/[\r\n]/, "").gsub(/\s+/, " ")
650
+ oldlen = @snippet.length
584
651
  @snippet = @snippet[0 ... SNIPPET_LEN].chomp
652
+ @snippet += "..." if @snippet.length < oldlen
585
653
  @dirty = true unless encrypted && $config[:discard_snippets_from_encrypted_messages]
586
654
  @snippet_contains_encrypted_content = true if encrypted
587
655
  end
@@ -600,4 +668,43 @@ private
600
668
  end
601
669
  end
602
670
 
671
+ class Location
672
+ attr_reader :source
673
+ attr_reader :info
674
+
675
+ def initialize source, info
676
+ @source = source
677
+ @info = info
678
+ end
679
+
680
+ def raw_header
681
+ source.raw_header info
682
+ end
683
+
684
+ def raw_message
685
+ source.raw_message info
686
+ end
687
+
688
+ ## much faster than raw_message
689
+ def each_raw_message_line &b
690
+ source.each_raw_message_line info, &b
691
+ end
692
+
693
+ def parsed_message
694
+ source.load_message info
695
+ end
696
+
697
+ def valid?
698
+ source.valid? info
699
+ end
700
+
701
+ def == o
702
+ o.source.id == source.id and o.info == info
703
+ end
704
+
705
+ def hash
706
+ [source.id, info].hash
707
+ end
708
+ end
709
+
603
710
  end
@@ -21,12 +21,13 @@ class ComposeMode < EditMessageMode
21
21
  end
22
22
 
23
23
  def self.spawn_nicely opts={}
24
+ from = opts[:from] || (BufferManager.ask_for_account(:account, "From (default #{AccountManager.default_account.email}): ") or return if $config[:ask_for_from])
24
25
  to = opts[:to] || (BufferManager.ask_for_contacts(:people, "To: ", [opts[:to_default]]) or return if ($config[:ask_for_to] != false))
25
26
  cc = opts[:cc] || (BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc])
26
27
  bcc = opts[:bcc] || (BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc])
27
28
  subj = opts[:subj] || (BufferManager.ask(:subject, "Subject: ") or return if $config[:ask_for_subject])
28
29
 
29
- mode = ComposeMode.new :from => opts[:from], :to => to, :cc => cc, :bcc => bcc, :subj => subj
30
+ mode = ComposeMode.new :from => from, :to => to, :cc => cc, :bcc => bcc, :subj => subj
30
31
  BufferManager.spawn "New Message", mode
31
32
  mode.edit_message
32
33
  end
@@ -8,7 +8,7 @@ class Console
8
8
  end
9
9
 
10
10
  def query(query)
11
- Enumerable::Enumerator.new(Index, :each_message, Index.parse_query(query))
11
+ Enumerator.new(Index.instance, :each_message, Index.parse_query(query))
12
12
  end
13
13
 
14
14
  def add_labels(query, *labels)
@@ -26,6 +26,9 @@ class Console
26
26
 
27
27
  def special_methods; methods - Object.methods end
28
28
 
29
+ def puts x; @mode << "#{x.to_s.rstrip}\n" end
30
+ def p x; puts x.inspect end
31
+
29
32
  ## files that won't cause problems when reloaded
30
33
  ## TODO expand this list / convert to blacklist
31
34
  RELOAD_WHITELIST = %w(sup/index.rb sup/modes/console-mode.rb)
@@ -1,7 +1,6 @@
1
1
  require 'tempfile'
2
2
  require 'socket' # just for gethostname!
3
3
  require 'pathname'
4
- require 'rmail'
5
4
 
6
5
  module Redwood
7
6
 
@@ -58,6 +57,18 @@ Return value:
58
57
  none
59
58
  EOS
60
59
 
60
+ HookManager.register "sendmail", <<EOS
61
+ Sends the given mail. If this hook doesn't exist, the sendmail command
62
+ configured for the account is used.
63
+ The message will be saved after this hook is run, so any modification to it
64
+ will be recorded.
65
+ Variables:
66
+ message: RMail::Message instance of the mail to send
67
+ account: Account instance matching the From address
68
+ Return value:
69
+ True if mail has been sent successfully, false otherwise.
70
+ EOS
71
+
61
72
  attr_reader :status
62
73
  attr_accessor :body, :header
63
74
  bool_reader :edited
@@ -207,7 +218,7 @@ protected
207
218
  def mime_encode string
208
219
  string = [string].pack('M') # basic quoted-printable
209
220
  string.gsub!(/=\n/,'') # .. remove trailing newline
210
- string.gsub!(/_/,'=96') # .. encode underscores
221
+ string.gsub!(/_/,'=5F') # .. encode underscores
211
222
  string.gsub!(/\?/,'=3F') # .. encode question marks
212
223
  string.gsub!(/ /,'_') # .. translate space to underscores
213
224
  "=?utf-8?q?#{string}?="
@@ -341,8 +352,17 @@ protected
341
352
  begin
342
353
  date = Time.now
343
354
  m = build_message date
344
- IO.popen(acct.sendmail, "w") { |p| p.puts m }
345
- raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0
355
+
356
+ if HookManager.enabled? "sendmail"
357
+ if not HookManager.run "sendmail", :message => m, :account => acct
358
+ warn "Sendmail hook was not successful"
359
+ return false
360
+ end
361
+ else
362
+ IO.popen(acct.sendmail, "w") { |p| p.puts m }
363
+ raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0
364
+ end
365
+
346
366
  SentManager.write_sent_message(date, from_email) { |f| f.puts sanitize_body(m.to_s) }
347
367
  BufferManager.kill_buffer buffer
348
368
  BufferManager.flash "Message sent!"
@@ -382,6 +402,11 @@ protected
382
402
  if @crypto_selector && @crypto_selector.val != :none
383
403
  from_email = Person.from_address(@header["From"]).email
384
404
  to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
405
+ if m.multipart?
406
+ m.each_part {|p| p = transfer_encode p}
407
+ else
408
+ m = transfer_encode m
409
+ end
385
410
 
386
411
  m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
387
412
  end
@@ -401,7 +426,8 @@ protected
401
426
  m.header["Date"] = date.rfc2822
402
427
  m.header["Message-Id"] = @message_id
403
428
  m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
404
- m.header["Content-Transfer-Encoding"] = '8bit'
429
+ m.header["Content-Transfer-Encoding"] ||= '8bit'
430
+ m.header["MIME-Version"] = "1.0" if m.multipart?
405
431
  m
406
432
  end
407
433
 
@@ -497,6 +523,25 @@ private
497
523
  []
498
524
  end
499
525
  end
526
+
527
+ def transfer_encode msg_part
528
+ ## return the message unchanged if it's already encoded
529
+ if (msg_part.header["Content-Transfer-Encoding"] == "base64" ||
530
+ msg_part.header["Content-Transfer-Encoding"] == "quoted-printable")
531
+ return msg_part
532
+ end
533
+
534
+ ## encode to quoted-printable for all text/* MIME types,
535
+ ## use base64 otherwise
536
+ if msg_part.header["Content-Type"] =~ /text\/.*/
537
+ msg_part.header["Content-Transfer-Encoding"] = 'quoted-printable'
538
+ msg_part.body = [msg_part.body].pack('M')
539
+ else
540
+ msg_part.header["Content-Transfer-Encoding"] = 'base64'
541
+ msg_part.body = [msg_part.body].pack('m')
542
+ end
543
+ msg_part
544
+ end
500
545
  end
501
546
 
502
547
  end