sup 0.2 → 0.3

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.

Files changed (43) hide show
  1. data/History.txt +10 -0
  2. data/bin/sup +50 -68
  3. data/doc/NewUserGuide.txt +11 -7
  4. data/doc/TODO +34 -22
  5. data/lib/sup.rb +30 -24
  6. data/lib/sup/buffer.rb +124 -39
  7. data/lib/sup/colormap.rb +4 -4
  8. data/lib/sup/draft.rb +1 -1
  9. data/lib/sup/hook.rb +18 -5
  10. data/lib/sup/imap.rb +11 -13
  11. data/lib/sup/index.rb +52 -14
  12. data/lib/sup/keymap.rb +1 -1
  13. data/lib/sup/logger.rb +1 -0
  14. data/lib/sup/maildir.rb +9 -0
  15. data/lib/sup/mbox.rb +3 -1
  16. data/lib/sup/message-chunks.rb +21 -7
  17. data/lib/sup/message.rb +31 -15
  18. data/lib/sup/mode.rb +2 -0
  19. data/lib/sup/modes/buffer-list-mode.rb +7 -3
  20. data/lib/sup/modes/compose-mode.rb +14 -16
  21. data/lib/sup/modes/contact-list-mode.rb +2 -2
  22. data/lib/sup/modes/edit-message-mode.rb +55 -23
  23. data/lib/sup/modes/forward-mode.rb +22 -5
  24. data/lib/sup/modes/inbox-mode.rb +3 -7
  25. data/lib/sup/modes/label-list-mode.rb +30 -10
  26. data/lib/sup/modes/label-search-results-mode.rb +12 -0
  27. data/lib/sup/modes/line-cursor-mode.rb +13 -0
  28. data/lib/sup/modes/log-mode.rb +0 -6
  29. data/lib/sup/modes/poll-mode.rb +0 -3
  30. data/lib/sup/modes/reply-mode.rb +19 -11
  31. data/lib/sup/modes/scroll-mode.rb +111 -20
  32. data/lib/sup/modes/search-results-mode.rb +21 -0
  33. data/lib/sup/modes/text-mode.rb +10 -2
  34. data/lib/sup/modes/thread-index-mode.rb +200 -90
  35. data/lib/sup/modes/thread-view-mode.rb +27 -10
  36. data/lib/sup/person.rb +1 -0
  37. data/lib/sup/poll.rb +15 -7
  38. data/lib/sup/source.rb +6 -1
  39. data/lib/sup/suicide.rb +1 -1
  40. data/lib/sup/textfield.rb +14 -14
  41. data/lib/sup/thread.rb +6 -2
  42. data/lib/sup/util.rb +111 -9
  43. metadata +13 -6
@@ -44,7 +44,7 @@ class Keymap
44
44
  when :end: "<end>"
45
45
  when :enter, :return: "<enter>"
46
46
  when :ctrl_l: "ctrl-l"
47
- when :ctrl_l: "ctrl-g"
47
+ when :ctrl_g: "ctrl-g"
48
48
  when :tab: "tab"
49
49
  when " ": "<space>"
50
50
  else
@@ -51,3 +51,4 @@ class Logger
51
51
  end
52
52
 
53
53
  end
54
+
@@ -39,6 +39,15 @@ class Maildir < Source
39
39
 
40
40
  start = @ids.index(cur_offset || start_offset) or raise OutOfSyncSourceError, "Unknown message id #{cur_offset || start_offset}." # couldn't find the most recent email
41
41
  end
42
+
43
+ def each_raw_message_line id
44
+ scan_mailbox
45
+ with_file_for(id) do |f|
46
+ until f.eof?
47
+ yield f.gets
48
+ end
49
+ end
50
+ end
42
51
 
43
52
  def load_header id
44
53
  scan_mailbox
@@ -30,6 +30,8 @@ module MBox
30
30
  /^(In-Reply-To):\s+(.*?)\s*$/i,
31
31
  /^(Reply-To):\s+(.*?)\s*$/i,
32
32
  /^(List-Post):\s+(.*?)\s*$/i,
33
+ /^(List-Subscribe):\s+(.*?)\s*$/i,
34
+ /^(List-Unsubscribe):\s+(.*?)\s*$/i,
33
35
  /^(Status):\s+(.*?)\s*$/i: header[last = $1] = $2
34
36
  when /^(Message-Id):\s+(.*?)\s*$/i: header[mid_field = last = $1] = $2
35
37
 
@@ -40,7 +42,7 @@ module MBox
40
42
  /^(Envelope-To):\s+(.*)$/i: header[last = $1] ||= $2
41
43
 
42
44
  when /^$/: break
43
- when /:/: last = nil # some other header we don't care about
45
+ when /^\S+: /: last = nil # some other header we don't care about
44
46
  else
45
47
  header[last] += " " + line.chomp.gsub(/^\s+/, "") if last
46
48
  end
@@ -20,10 +20,14 @@
20
20
  ## :open if they want to start expanded (default is to start collapsed).
21
21
  ##
22
22
  ## If it's not expandable but is viewable, a patina is displayed using
23
- ###patina_color and #patina_text, but no toggling is allowed. Instead,
24
- ##if #view! is defined, pressing enter on the widget calls view! and
25
- ##(if that returns false) #to_s. Otherwise, enter does nothing. This
26
- ##is how non-inlineable attachments work.
23
+ ## #patina_color and #patina_text, but no toggling is allowed. Instead,
24
+ ## if #view! is defined, pressing enter on the widget calls view! and
25
+ ## (if that returns false) #to_s. Otherwise, enter does nothing. This
26
+ ## is how non-inlineable attachments work.
27
+ ##
28
+ ## Independent of all that, a chunk can be quotable, in which case it's
29
+ ## included as quoted text during a reply. Text, Quotes, and mime-parsed
30
+ ## attachments are quotable; Signatures are not.
27
31
 
28
32
  module Redwood
29
33
  module Chunk
@@ -45,10 +49,12 @@ EOS
45
49
  ## raw_content is the post-MIME-decode content. this is used for
46
50
  ## saving the attachment to disk.
47
51
  attr_reader :content_type, :filename, :lines, :raw_content
52
+ bool_reader :quotable
48
53
 
49
54
  def initialize content_type, filename, encoded_content, sibling_types
50
55
  @content_type = content_type
51
56
  @filename = filename
57
+ @quotable = false # only quotable if we can parse it through the mime-decode hook
52
58
  @raw_content =
53
59
  if encoded_content.body
54
60
  encoded_content.decode
@@ -64,7 +70,10 @@ EOS
64
70
  text = HookManager.run "mime-decode", :content_type => content_type,
65
71
  :filename => lambda { write_to_disk },
66
72
  :sibling_types => sibling_types
67
- text.split("\n") if text
73
+ if text
74
+ @quotable = true
75
+ text.split("\n")
76
+ end
68
77
  end
69
78
  end
70
79
 
@@ -86,7 +95,7 @@ EOS
86
95
  def viewable?; @lines.nil? end
87
96
  def view!
88
97
  path = write_to_disk
89
- system "/usr/bin/run-mailcap --action=view #{@content_type}:#{path} >& /dev/null"
98
+ system "/usr/bin/run-mailcap --action=view #{@content_type}:#{path} > /dev/null 2> /dev/null"
90
99
  $? == 0
91
100
  end
92
101
 
@@ -111,10 +120,11 @@ EOS
111
120
  @lines = lines.map { |l| l.chomp.wrap WRAP_LEN }.flatten # wrap
112
121
 
113
122
  ## trim off all empty lines except one
114
- lines.pop while lines.last =~ /^\s*$/
123
+ @lines.pop while @lines.length > 1 && @lines[-1] =~ /^\s*$/ && @lines[-2] =~ /^\s*$/
115
124
  end
116
125
 
117
126
  def inlineable?; true end
127
+ def quotable?; true end
118
128
  def expandable?; false end
119
129
  def viewable?; false end
120
130
  def color; :none end
@@ -127,6 +137,7 @@ EOS
127
137
  end
128
138
 
129
139
  def inlineable?; @lines.length == 1 end
140
+ def quotable?; true end
130
141
  def expandable?; !inlineable? end
131
142
  def viewable?; false end
132
143
 
@@ -142,6 +153,7 @@ EOS
142
153
  end
143
154
 
144
155
  def inlineable?; @lines.length == 1 end
156
+ def quotable?; false end
145
157
  def expandable?; !inlineable? end
146
158
  def viewable?; false end
147
159
 
@@ -162,6 +174,7 @@ EOS
162
174
  end
163
175
 
164
176
  def inlineable?; false end
177
+ def quotable?; false end
165
178
  def expandable?; true end
166
179
  def initial_state; :open end
167
180
  def viewable?; false end
@@ -191,6 +204,7 @@ EOS
191
204
  def color; patina_color end
192
205
 
193
206
  def inlineable?; false end
207
+ def quotable?; false end
194
208
  def expandable?; !@lines.empty? end
195
209
  def viewable?; false end
196
210
  end
@@ -13,6 +13,10 @@ class MessageFormatError < StandardError; end
13
13
  ## i would like, for example, to be able to add in a ruby-talk
14
14
  ## specific module that would detect and link to /ruby-talk:\d+/
15
15
  ## sequences in the text of an email. (how sweet would that be?)
16
+ ##
17
+ ## this class cathces all source exceptions. if the underlying source throws
18
+ ## an error, it is caught and handled.
19
+
16
20
  class Message
17
21
  SNIPPET_LEN = 80
18
22
  RE_PATTERN = /^((re|re[\[\(]\d[\]\)]):\s*)+/i
@@ -35,7 +39,7 @@ class Message
35
39
 
36
40
  attr_reader :id, :date, :from, :subj, :refs, :replytos, :to, :source,
37
41
  :cc, :bcc, :labels, :list_address, :recipient_email, :replyto,
38
- :source_info, :chunks
42
+ :source_info, :chunks, :list_subscribe, :list_unsubscribe
39
43
 
40
44
  bool_reader :dirty, :source_marked_read
41
45
 
@@ -56,16 +60,24 @@ class Message
56
60
  def parse_header header
57
61
  header.each { |k, v| header[k.downcase] = v }
58
62
 
59
- @from = PersonManager.person_for header["from"]
60
-
61
63
  @id =
62
64
  if header["message-id"]
63
65
  sanitize_message_id header["message-id"]
64
66
  else
65
- "sup-faked-" + Digest::MD5.hexdigest(raw_header)
66
- Redwood::log "faking message-id for message from #@from: #@id"
67
+ returning("sup-faked-" + Digest::MD5.hexdigest(raw_header)) do |id|
68
+ Redwood::log "faking message-id for message from #@from: #{id}"
69
+ end
67
70
  end
68
71
 
72
+ @from =
73
+ if header["from"]
74
+ PersonManager.person_for header["from"]
75
+ else
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
79
+ end
80
+
69
81
  date = header["date"]
70
82
  @date =
71
83
  case date
@@ -99,6 +111,8 @@ class Message
99
111
 
100
112
  @recipient_email = header["envelope-to"] || header["x-original-to"] || header["delivered-to"]
101
113
  @source_marked_read = header["status"] == "RO"
114
+ @list_subscribe = header["list-subscribe"]
115
+ @list_unsubscribe = header["list-unsubscribe"]
102
116
  end
103
117
  private :parse_header
104
118
 
@@ -159,6 +173,7 @@ class Message
159
173
  Redwood::log "problem getting messages from #{@source}: #{e.message}"
160
174
  ## we need force_to_top here otherwise this window will cover
161
175
  ## up the error message one
176
+ @source.error ||= e
162
177
  Redwood::report_broken_sources :force_to_top => true
163
178
  [Chunk::Text.new(error_message(e.message))]
164
179
  end
@@ -184,11 +199,14 @@ The error message was:
184
199
  EOS
185
200
  end
186
201
 
202
+ ## wrap any source methods that might throw sourceerrors
187
203
  def with_source_errors_handled
188
204
  begin
189
205
  yield
190
206
  rescue SourceError => e
191
207
  Redwood::log "problem getting messages from #{@source}: #{e.message}"
208
+ @source.error ||= e
209
+ Redwood::report_broken_sources :force_to_top => true
192
210
  error_message e.message
193
211
  end
194
212
  end
@@ -218,11 +236,11 @@ EOS
218
236
  ].flatten.compact.join " "
219
237
  end
220
238
 
221
- def basic_body_lines
222
- chunks.find_all { |c| c.is_a?(Chunk::Text) || c.is_a?(Chunk::Quote) }.map { |c| c.lines }.flatten
239
+ def quotable_body_lines
240
+ chunks.find_all { |c| c.quotable? }.map { |c| c.lines }.flatten
223
241
  end
224
242
 
225
- def basic_header_lines
243
+ def quotable_header_lines
226
244
  ["From: #{@from.full_address}"] +
227
245
  (@to.empty? ? [] : ["To: " + @to.map { |p| p.full_address }.join(", ")]) +
228
246
  (@cc.empty? ? [] : ["Cc: " + @cc.map { |p| p.full_address }.join(", ")]) +
@@ -334,11 +352,9 @@ private
334
352
  else
335
353
  filename =
336
354
  ## first, paw through the headers looking for a filename
337
- if m.header["Content-Disposition"] &&
338
- m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
355
+ if m.header["Content-Disposition"] && m.header["Content-Disposition"] =~ /filename="?(.*?[^\\])("|;|$)/
339
356
  $1
340
- elsif m.header["Content-Type"] &&
341
- m.header["Content-Type"] =~ /name=(.*?)(;|$)/
357
+ elsif m.header["Content-Type"] && m.header["Content-Type"] =~ /name="?(.*?[^\\])("|;|$)/
342
358
  $1
343
359
 
344
360
  ## haven't found one, but it's a non-text message. fake
@@ -360,11 +376,11 @@ private
360
376
  end
361
377
 
362
378
  def self.convert_from body, charset
363
- return body unless charset
364
-
365
379
  begin
380
+ raise MessageFormatError, "RubyMail decode returned a null body" unless body
381
+ return body unless charset
366
382
  Iconv.iconv($encoding, charset, body).join
367
- rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
383
+ rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
368
384
  Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
369
385
  File.open("sup-unable-to-decode.txt", "w") { |f| f.write body }
370
386
  body
@@ -27,6 +27,8 @@ class Mode
27
27
  def draw; end
28
28
  def focus; end
29
29
  def blur; end
30
+ def cancel_search!; end
31
+ def in_search?; false end
30
32
  def status; ""; end
31
33
  def resize rows, cols; end
32
34
  def cleanup
@@ -3,7 +3,7 @@ module Redwood
3
3
  class BufferListMode < LineCursorMode
4
4
  register_keymap do |k|
5
5
  k.add :jump_to_buffer, "Jump to selected buffer", :enter
6
- k.add :reload, "Reload buffer list", "R"
6
+ k.add :reload, "Reload buffer list", "@"
7
7
  end
8
8
 
9
9
  def initialize
@@ -11,8 +11,12 @@ class BufferListMode < LineCursorMode
11
11
  super
12
12
  end
13
13
 
14
- def lines; @text.length; end
15
- def [] i; @text[i]; end
14
+ def lines; @text.length end
15
+ def [] i; @text[i] end
16
+
17
+ def focus
18
+ reload # buffers may have been killed or created since last view
19
+ end
16
20
 
17
21
  protected
18
22
 
@@ -1,23 +1,10 @@
1
1
  module Redwood
2
2
 
3
- module CanSpawnComposeMode
4
- def spawn_compose_mode opts={}
5
- to = opts[:to] || BufferManager.ask_for_contacts(:people, "To: ") or return
6
- cc = opts[:cc] || BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc]
7
- bcc = opts[:bcc] || BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc]
8
-
9
- mode = ComposeMode.new :to => to, :cc => cc, :bcc => bcc
10
- BufferManager.spawn "New Message", mode
11
- mode.edit_message
12
- end
13
- end
14
-
15
3
  class ComposeMode < EditMessageMode
16
4
  def initialize opts={}
17
- header = {
18
- "From" => AccountManager.default_account.full_address,
19
- }
20
-
5
+ header = {}
6
+ header["From"] = (opts[:from] || AccountManager.default_account).full_address
7
+ header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
21
8
  header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
22
9
  header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc]
23
10
  header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc]
@@ -31,6 +18,17 @@ class ComposeMode < EditMessageMode
31
18
  BufferManager.kill_buffer self.buffer unless edited
32
19
  edited
33
20
  end
21
+
22
+ def self.spawn_nicely opts={}
23
+ to = opts[:to] || BufferManager.ask_for_contacts(:people, "To: ") or return
24
+ cc = opts[:cc] || BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc]
25
+ bcc = opts[:bcc] || BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc]
26
+ subj = opts[:subj] || BufferManager.ask(:subject, "Subject: ") or return if $config[:ask_for_subject]
27
+
28
+ mode = ComposeMode.new :from => opts[:from], :to => to, :cc => cc, :bcc => bcc, :subj => subj
29
+ BufferManager.spawn "New Message", mode
30
+ mode.edit_message
31
+ end
34
32
  end
35
33
 
36
34
  end
@@ -59,7 +59,7 @@ class ContactListMode < LineCursorMode
59
59
  @num += num
60
60
  load
61
61
  update
62
- BufferManager.flash "Added #{num} contacts."
62
+ BufferManager.flash "Added #{num.pluralize 'contact'}."
63
63
  end
64
64
 
65
65
  def multi_select people
@@ -94,7 +94,7 @@ class ContactListMode < LineCursorMode
94
94
  end
95
95
 
96
96
  def load_in_background
97
- Redwood::reporting_thread do
97
+ Redwood::reporting_thread("contact manager load in bg") do
98
98
  load
99
99
  update
100
100
  BufferManager.draw_screen
@@ -13,7 +13,7 @@ class EditMessageMode < LineCursorMode
13
13
  NON_EDITABLE_HEADERS = %w(Message-Id Date)
14
14
 
15
15
  HookManager.register "signature", <<EOS
16
- Generates a signature for a message.
16
+ Generates a message signature.
17
17
  Variables:
18
18
  header: an object that supports string-to-string hashtable-style access
19
19
  to the raw headers for the message. E.g., header["From"],
@@ -24,13 +24,26 @@ Return value:
24
24
  use the default signature.
25
25
  EOS
26
26
 
27
+ HookManager.register "before-edit", <<EOS
28
+ Modifies message body and headers before editing a new message. Variables
29
+ should be modified in place.
30
+ Variables:
31
+ header: a hash of headers. See 'signature' hook for documentation.
32
+ body: an array of lines of body text.
33
+ Return value:
34
+ none
35
+ EOS
36
+
27
37
  attr_reader :status
28
38
  attr_accessor :body, :header
29
39
  bool_reader :edited
30
40
 
31
41
  register_keymap do |k|
32
42
  k.add :send_message, "Send message", 'y'
33
- k.add :edit_field, "Edit field", 'e'
43
+ k.add :edit_message_or_field, "Edit selected field", 'e'
44
+ k.add :edit_to, "Edit To:", 't'
45
+ k.add :edit_cc, "Edit Cc:", 'c'
46
+ k.add :edit_subject, "Edit Subject", 's'
34
47
  k.add :edit_message, "Edit message", :enter
35
48
  k.add :save_as_draft, "Save as draft", 'P'
36
49
  k.add :attach_file, "Attach a file", 'a'
@@ -48,6 +61,8 @@ EOS
48
61
  @message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{Socket.gethostname}>"
49
62
  @edited = false
50
63
  @skip_top_rows = opts[:skip_top_rows] || 0
64
+
65
+ HookManager.run "before-edit", :header => @header, :body => @body
51
66
 
52
67
  super opts
53
68
  regen_text
@@ -59,33 +74,18 @@ EOS
59
74
  ## a hook
60
75
  def handle_new_text header, body; end
61
76
 
62
- def edit_field
77
+ def edit_message_or_field
63
78
  if (curpos - @skip_top_rows) >= @header_lines.length
64
79
  edit_message
65
80
  else
66
- case(field = @header_lines[curpos - @skip_top_rows])
67
- when "Subject"
68
- text = BufferManager.ask :subject, "Subject: ", @header[field]
69
- @header[field] = parse_header field, text if text
70
- else
71
- default =
72
- case field
73
- when *MULTI_HEADERS
74
- @header[field].join(", ")
75
- else
76
- @header[field]
77
- end
78
-
79
- contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
80
- if contacts
81
- text = contacts.map { |s| s.longname }.join(", ")
82
- @header[field] = parse_header field, text
83
- end
84
- end
85
- update
81
+ edit_field @header_lines[curpos - @skip_top_rows]
86
82
  end
87
83
  end
88
84
 
85
+ def edit_to; edit_field "To" end
86
+ def edit_cc; edit_field "Cc" end
87
+ def edit_subject; edit_field "Subject" end
88
+
89
89
  def edit_message
90
90
  @file = Tempfile.new "sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}"
91
91
  @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first
@@ -139,6 +139,8 @@ protected
139
139
  header, @header_lines = format_headers(@header - NON_EDITABLE_HEADERS) + [""]
140
140
  @text = header + [""] + @body
141
141
  @text += sig_lines unless $config[:edit_signature]
142
+
143
+ @attachment_lines_offset = 0
142
144
 
143
145
  unless @attachments.empty?
144
146
  @text += [""]
@@ -298,6 +300,36 @@ EOS
298
300
  f.puts sig_lines if full unless $config[:edit_signature]
299
301
  end
300
302
 
303
+ protected
304
+
305
+ def edit_field field
306
+ case field
307
+ when "Subject"
308
+ text = BufferManager.ask :subject, "Subject: ", @header[field]
309
+ if text
310
+ @header[field] = parse_header field, text
311
+ update
312
+ field
313
+ end
314
+ else
315
+ default =
316
+ case field
317
+ when *MULTI_HEADERS
318
+ @header[field].join(", ")
319
+ else
320
+ @header[field]
321
+ end
322
+
323
+ contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
324
+ if contacts
325
+ text = contacts.map { |s| s.longname }.join(", ")
326
+ @header[field] = parse_header field, text
327
+ update
328
+ field
329
+ end
330
+ end
331
+ end
332
+
301
333
  private
302
334
 
303
335
  def sanitize_body body