sup 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +12 -0
- data/CONTRIBUTORS +84 -0
- data/Gemfile +3 -0
- data/HACKING +42 -0
- data/History.txt +361 -0
- data/LICENSE +280 -0
- data/README.md +70 -0
- data/Rakefile +12 -0
- data/ReleaseNotes +231 -0
- data/bin/sup +434 -0
- data/bin/sup-add +118 -0
- data/bin/sup-config +243 -0
- data/bin/sup-dump +43 -0
- data/bin/sup-import-dump +101 -0
- data/bin/sup-psych-ify-config-files +21 -0
- data/bin/sup-recover-sources +87 -0
- data/bin/sup-sync +210 -0
- data/bin/sup-sync-back-maildir +127 -0
- data/bin/sup-tweak-labels +140 -0
- data/contrib/colorpicker.rb +100 -0
- data/contrib/completion/_sup.zsh +114 -0
- data/devel/console.sh +3 -0
- data/devel/count-loc.sh +3 -0
- data/devel/load-index.rb +9 -0
- data/devel/profile.rb +12 -0
- data/devel/start-console.rb +5 -0
- data/doc/FAQ.txt +119 -0
- data/doc/Hooks.txt +79 -0
- data/doc/Philosophy.txt +69 -0
- data/lib/sup.rb +467 -0
- data/lib/sup/account.rb +90 -0
- data/lib/sup/buffer.rb +768 -0
- data/lib/sup/colormap.rb +239 -0
- data/lib/sup/contact.rb +67 -0
- data/lib/sup/crypto.rb +461 -0
- data/lib/sup/draft.rb +119 -0
- data/lib/sup/hook.rb +159 -0
- data/lib/sup/horizontal_selector.rb +59 -0
- data/lib/sup/idle.rb +42 -0
- data/lib/sup/index.rb +882 -0
- data/lib/sup/interactive_lock.rb +89 -0
- data/lib/sup/keymap.rb +140 -0
- data/lib/sup/label.rb +87 -0
- data/lib/sup/logger.rb +77 -0
- data/lib/sup/logger/singleton.rb +10 -0
- data/lib/sup/maildir.rb +257 -0
- data/lib/sup/mbox.rb +187 -0
- data/lib/sup/message.rb +803 -0
- data/lib/sup/message_chunks.rb +328 -0
- data/lib/sup/mode.rb +140 -0
- data/lib/sup/modes/buffer_list_mode.rb +50 -0
- data/lib/sup/modes/completion_mode.rb +55 -0
- data/lib/sup/modes/compose_mode.rb +38 -0
- data/lib/sup/modes/console_mode.rb +125 -0
- data/lib/sup/modes/contact_list_mode.rb +148 -0
- data/lib/sup/modes/edit_message_async_mode.rb +110 -0
- data/lib/sup/modes/edit_message_mode.rb +728 -0
- data/lib/sup/modes/file_browser_mode.rb +109 -0
- data/lib/sup/modes/forward_mode.rb +82 -0
- data/lib/sup/modes/help_mode.rb +19 -0
- data/lib/sup/modes/inbox_mode.rb +85 -0
- data/lib/sup/modes/label_list_mode.rb +138 -0
- data/lib/sup/modes/label_search_results_mode.rb +38 -0
- data/lib/sup/modes/line_cursor_mode.rb +203 -0
- data/lib/sup/modes/log_mode.rb +57 -0
- data/lib/sup/modes/person_search_results_mode.rb +12 -0
- data/lib/sup/modes/poll_mode.rb +19 -0
- data/lib/sup/modes/reply_mode.rb +228 -0
- data/lib/sup/modes/resume_mode.rb +52 -0
- data/lib/sup/modes/scroll_mode.rb +252 -0
- data/lib/sup/modes/search_list_mode.rb +204 -0
- data/lib/sup/modes/search_results_mode.rb +59 -0
- data/lib/sup/modes/text_mode.rb +76 -0
- data/lib/sup/modes/thread_index_mode.rb +1033 -0
- data/lib/sup/modes/thread_view_mode.rb +941 -0
- data/lib/sup/person.rb +134 -0
- data/lib/sup/poll.rb +272 -0
- data/lib/sup/rfc2047.rb +56 -0
- data/lib/sup/search.rb +110 -0
- data/lib/sup/sent.rb +58 -0
- data/lib/sup/service/label_service.rb +45 -0
- data/lib/sup/source.rb +244 -0
- data/lib/sup/tagger.rb +50 -0
- data/lib/sup/textfield.rb +253 -0
- data/lib/sup/thread.rb +452 -0
- data/lib/sup/time.rb +93 -0
- data/lib/sup/undo.rb +38 -0
- data/lib/sup/update.rb +30 -0
- data/lib/sup/util.rb +747 -0
- data/lib/sup/util/ncurses.rb +274 -0
- data/lib/sup/util/path.rb +9 -0
- data/lib/sup/util/query.rb +17 -0
- data/lib/sup/util/uri.rb +15 -0
- data/lib/sup/version.rb +3 -0
- data/sup.gemspec +53 -0
- data/test/dummy_source.rb +61 -0
- data/test/gnupg_test_home/gpg.conf +1 -0
- data/test/gnupg_test_home/pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_secring.gpg +0 -0
- data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
- data/test/gnupg_test_home/secring.gpg +0 -0
- data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
- data/test/gnupg_test_home/trustdb.gpg +0 -0
- data/test/integration/test_label_service.rb +18 -0
- data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
- data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
- data/test/messages/missing-line.eml +9 -0
- data/test/test_crypto.rb +109 -0
- data/test/test_header_parsing.rb +168 -0
- data/test/test_helper.rb +7 -0
- data/test/test_message.rb +532 -0
- data/test/test_messages_dir.rb +147 -0
- data/test/test_yaml_migration.rb +85 -0
- data/test/test_yaml_regressions.rb +17 -0
- data/test/unit/service/test_label_service.rb +19 -0
- data/test/unit/test_horizontal_selector.rb +40 -0
- data/test/unit/util/test_query.rb +46 -0
- data/test/unit/util/test_string.rb +57 -0
- data/test/unit/util/test_uri.rb +19 -0
- metadata +423 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
|
|
3
|
+
module Redwood
|
|
4
|
+
|
|
5
|
+
## wrap a nice interactive layer on top of anything that has a #lock method
|
|
6
|
+
## which throws a LockError which responds to #user, #host, #mtim, #pname, and
|
|
7
|
+
## #pid.
|
|
8
|
+
|
|
9
|
+
module InteractiveLock
|
|
10
|
+
def pluralize number_of, kind; "#{number_of} #{kind}" + (number_of == 1 ? "" : "s") end
|
|
11
|
+
|
|
12
|
+
def time_ago_in_words time
|
|
13
|
+
secs = (Time.now - time).to_i
|
|
14
|
+
mins = secs / 60
|
|
15
|
+
time = if mins == 0
|
|
16
|
+
pluralize secs, "second"
|
|
17
|
+
else
|
|
18
|
+
pluralize mins, "minute"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
DELAY = 5 # seconds
|
|
23
|
+
|
|
24
|
+
def lock_interactively stream=$stderr
|
|
25
|
+
begin
|
|
26
|
+
Index.lock
|
|
27
|
+
rescue Index::LockError => e
|
|
28
|
+
begin
|
|
29
|
+
Process.kill 0, e.pid.to_i # 0 signal test the existence of PID
|
|
30
|
+
stream.puts <<EOS
|
|
31
|
+
Error: the index is locked by another process! User '#{e.user}' on
|
|
32
|
+
host '#{e.host}' is running #{e.pname} with pid #{e.pid}.
|
|
33
|
+
The process was alive as of at least #{time_ago_in_words e.mtime} ago.
|
|
34
|
+
|
|
35
|
+
EOS
|
|
36
|
+
stream.print "Should I ask that process to kill itself (y/n)? "
|
|
37
|
+
stream.flush
|
|
38
|
+
if $stdin.gets =~ /^\s*y(es)?\s*$/i
|
|
39
|
+
Process.kill "TERM", e.pid.to_i
|
|
40
|
+
sleep DELAY
|
|
41
|
+
stream.puts "Let's try that again."
|
|
42
|
+
begin
|
|
43
|
+
Index.lock
|
|
44
|
+
rescue Index::LockError => e
|
|
45
|
+
stream.puts "I couldn't lock the index. The lockfile might just be stale."
|
|
46
|
+
stream.print "Should I just remove it and continue? (y/n) "
|
|
47
|
+
stream.flush
|
|
48
|
+
if $stdin.gets =~ /^\s*y(es)?\s*$/i
|
|
49
|
+
begin
|
|
50
|
+
FileUtils.rm e.path
|
|
51
|
+
rescue Errno::ENOENT
|
|
52
|
+
stream.puts "The lockfile doesn't exists. We continue."
|
|
53
|
+
end
|
|
54
|
+
stream.puts "Let's try that one more time."
|
|
55
|
+
begin
|
|
56
|
+
Index.lock
|
|
57
|
+
rescue Index::LockError => e
|
|
58
|
+
stream.puts "I couldn't unlock the index."
|
|
59
|
+
return false
|
|
60
|
+
end
|
|
61
|
+
return true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
rescue Errno::ESRCH # no such process
|
|
66
|
+
stream.puts "I couldn't lock the index. The lockfile might just be stale."
|
|
67
|
+
begin
|
|
68
|
+
FileUtils.rm e.path
|
|
69
|
+
rescue Errno::ENOENT
|
|
70
|
+
stream.puts "The lockfile doesn't exists. We continue."
|
|
71
|
+
end
|
|
72
|
+
stream.puts "Let's try that one more time."
|
|
73
|
+
begin
|
|
74
|
+
sleep DELAY
|
|
75
|
+
Index.lock
|
|
76
|
+
rescue Index::LockError => e
|
|
77
|
+
stream.puts "I couldn't unlock the index."
|
|
78
|
+
return false
|
|
79
|
+
end
|
|
80
|
+
return true
|
|
81
|
+
end
|
|
82
|
+
stream.puts "Sorry, couldn't unlock the index."
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
return true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
end
|
data/lib/sup/keymap.rb
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
require 'sup/util/ncurses'
|
|
2
|
+
|
|
3
|
+
module Redwood
|
|
4
|
+
|
|
5
|
+
class Keymap
|
|
6
|
+
|
|
7
|
+
HookManager.register "keybindings", <<EOS
|
|
8
|
+
Add custom keybindings.
|
|
9
|
+
Methods:
|
|
10
|
+
modes: Hash from mode names to mode classes.
|
|
11
|
+
global_keymap: The top-level keymap.
|
|
12
|
+
EOS
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@map = {}
|
|
16
|
+
@order = []
|
|
17
|
+
yield self if block_given?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.keysym_to_keycode k
|
|
21
|
+
case k
|
|
22
|
+
when :down then Ncurses::KEY_DOWN
|
|
23
|
+
when :up then Ncurses::KEY_UP
|
|
24
|
+
when :left then Ncurses::KEY_LEFT
|
|
25
|
+
when :right then Ncurses::KEY_RIGHT
|
|
26
|
+
when :page_down then Ncurses::KEY_NPAGE
|
|
27
|
+
when :page_up then Ncurses::KEY_PPAGE
|
|
28
|
+
when :backspace then Ncurses::KEY_BACKSPACE
|
|
29
|
+
when :home then Ncurses::KEY_HOME
|
|
30
|
+
when :end then Ncurses::KEY_END
|
|
31
|
+
when :ctrl_l then "\f".ord
|
|
32
|
+
when :ctrl_g then "\a".ord
|
|
33
|
+
when :tab then "\t".ord
|
|
34
|
+
when :enter, :return then 10 #Ncurses::KEY_ENTER
|
|
35
|
+
else
|
|
36
|
+
if k.is_a?(String) && k.length == 1
|
|
37
|
+
k.ord
|
|
38
|
+
else
|
|
39
|
+
raise ArgumentError, "unknown key name '#{k}'"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.keysym_to_string k
|
|
45
|
+
case k
|
|
46
|
+
when :down then "<down arrow>"
|
|
47
|
+
when :up then "<up arrow>"
|
|
48
|
+
when :left then "<left arrow>"
|
|
49
|
+
when :right then "<right arrow>"
|
|
50
|
+
when :page_down then "<page down>"
|
|
51
|
+
when :page_up then "<page up>"
|
|
52
|
+
when :backspace then "<backspace>"
|
|
53
|
+
when :home then "<home>"
|
|
54
|
+
when :end then "<end>"
|
|
55
|
+
when :enter, :return then "<enter>"
|
|
56
|
+
when :tab then "tab"
|
|
57
|
+
when " " then "<space>"
|
|
58
|
+
else
|
|
59
|
+
Ncurses::keyname(keysym_to_keycode(k))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def add action, help, *keys
|
|
64
|
+
entry = [action, help, keys]
|
|
65
|
+
@order << entry
|
|
66
|
+
keys.each do |k|
|
|
67
|
+
kc = Keymap.keysym_to_keycode k
|
|
68
|
+
raise ArgumentError, "key '#{k}' already defined (as #{@map[kc].first})" if @map.include? kc
|
|
69
|
+
@map[kc] = entry
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def delete k
|
|
74
|
+
kc = Keymap.keysym_to_keycode(k)
|
|
75
|
+
return unless @map.member? kc
|
|
76
|
+
entry = @map.delete kc
|
|
77
|
+
keys = entry[2]
|
|
78
|
+
keys.delete k
|
|
79
|
+
@order.delete entry if keys.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add! action, help, *keys
|
|
83
|
+
keys.each { |k| delete k }
|
|
84
|
+
add action, help, *keys
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def add_multi prompt, key
|
|
88
|
+
kc = Keymap.keysym_to_keycode(key)
|
|
89
|
+
if @map.member? kc
|
|
90
|
+
action = @map[kc].first
|
|
91
|
+
raise "existing action is not a keymap" unless action.is_a?(Keymap)
|
|
92
|
+
yield action
|
|
93
|
+
else
|
|
94
|
+
submap = Keymap.new
|
|
95
|
+
add submap, prompt, key
|
|
96
|
+
yield submap
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def action_for kc
|
|
101
|
+
action, help, keys = @map[kc.code]
|
|
102
|
+
[action, help]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def has_key? k; @map[k.code] end
|
|
106
|
+
|
|
107
|
+
def keysyms; @map.values.map { |action, help, keys| keys }.flatten; end
|
|
108
|
+
|
|
109
|
+
def help_lines except_for={}, prefix=""
|
|
110
|
+
lines = [] # :(
|
|
111
|
+
@order.each do |action, help, keys|
|
|
112
|
+
valid_keys = keys.select { |k| !except_for[k] }
|
|
113
|
+
next if valid_keys.empty?
|
|
114
|
+
case action
|
|
115
|
+
when Symbol
|
|
116
|
+
lines << [valid_keys.map { |k| prefix + Keymap.keysym_to_string(k) }.join(", "), help]
|
|
117
|
+
when Keymap
|
|
118
|
+
lines += action.help_lines({}, prefix + Keymap.keysym_to_string(keys.first))
|
|
119
|
+
end
|
|
120
|
+
end.compact
|
|
121
|
+
lines
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def help_text except_for={}
|
|
125
|
+
lines = help_lines except_for
|
|
126
|
+
llen = lines.max_of { |a, b| a.length }
|
|
127
|
+
lines.map { |a, b| sprintf " %#{llen}s : %s", a, b }.join("\n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.run_hook global_keymap
|
|
131
|
+
modes = Hash[Mode.keymaps.map { |klass,keymap| [Mode.make_name(klass.name),klass] }]
|
|
132
|
+
locals = {
|
|
133
|
+
:modes => modes,
|
|
134
|
+
:global_keymap => global_keymap,
|
|
135
|
+
}
|
|
136
|
+
HookManager.run 'keybindings', locals
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
end
|
data/lib/sup/label.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
|
|
3
|
+
module Redwood
|
|
4
|
+
|
|
5
|
+
class LabelManager
|
|
6
|
+
include Redwood::Singleton
|
|
7
|
+
|
|
8
|
+
## labels that have special semantics. user will be unable to
|
|
9
|
+
## add/remove these via normal label mechanisms.
|
|
10
|
+
RESERVED_LABELS = [ :starred, :spam, :draft, :unread, :killed, :sent, :deleted, :inbox, :attachment, :forwarded, :replied ]
|
|
11
|
+
|
|
12
|
+
## labels that will typically be hidden from the user
|
|
13
|
+
HIDDEN_RESERVED_LABELS = [ :starred, :unread, :attachment, :forwarded, :replied ]
|
|
14
|
+
|
|
15
|
+
def initialize fn
|
|
16
|
+
@fn = fn
|
|
17
|
+
labels =
|
|
18
|
+
if File.exists? fn
|
|
19
|
+
IO.readlines(fn).map { |x| x.chomp.intern }
|
|
20
|
+
else
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
@labels = {}
|
|
24
|
+
@new_labels = {}
|
|
25
|
+
@modified = false
|
|
26
|
+
labels.each { |t| @labels[t] = true }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def new_label? l; @new_labels.include?(l) end
|
|
30
|
+
|
|
31
|
+
## all labels user-defined and system, ordered
|
|
32
|
+
## nicely and converted to pretty strings. use #label_for to recover
|
|
33
|
+
## the original label.
|
|
34
|
+
def all_labels
|
|
35
|
+
## uniq's only necessary here because of certain upgrade issues
|
|
36
|
+
(RESERVED_LABELS + @labels.keys).uniq
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
## all user-defined labels, ordered
|
|
40
|
+
## nicely and converted to pretty strings. use #label_for to recover
|
|
41
|
+
## the original label.
|
|
42
|
+
def user_defined_labels
|
|
43
|
+
@labels.keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
## reverse the label->string mapping, for convenience!
|
|
47
|
+
def string_for l
|
|
48
|
+
if RESERVED_LABELS.include? l
|
|
49
|
+
l.to_s.capitalize
|
|
50
|
+
else
|
|
51
|
+
l.to_s
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def label_for s
|
|
56
|
+
l = s.intern
|
|
57
|
+
l2 = s.downcase.intern
|
|
58
|
+
if RESERVED_LABELS.include? l2
|
|
59
|
+
l2
|
|
60
|
+
else
|
|
61
|
+
l
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def << t
|
|
66
|
+
raise ArgumentError, "expecting a symbol" unless t.is_a? Symbol
|
|
67
|
+
unless @labels.member?(t) || RESERVED_LABELS.member?(t)
|
|
68
|
+
@labels[t] = true
|
|
69
|
+
@new_labels[t] = true
|
|
70
|
+
@modified = true
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def delete t
|
|
75
|
+
if @labels.delete(t)
|
|
76
|
+
@modified = true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def save
|
|
81
|
+
return unless @modified
|
|
82
|
+
File.open(@fn, "w:UTF-8") { |f| f.puts @labels.keys.sort_by { |l| l.to_s } }
|
|
83
|
+
@new_labels = {}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
end
|
data/lib/sup/logger.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "sup/util"
|
|
2
|
+
require 'stringio'
|
|
3
|
+
require 'thread'
|
|
4
|
+
|
|
5
|
+
module Redwood
|
|
6
|
+
|
|
7
|
+
## simple centralized logger. outputs to multiple sinks by calling << on them.
|
|
8
|
+
## also keeps a record of all messages, so that adding a new sink will send all
|
|
9
|
+
## previous messages to it by default.
|
|
10
|
+
class Logger
|
|
11
|
+
include Redwood::Singleton
|
|
12
|
+
|
|
13
|
+
LEVELS = %w(debug info warn error) # in order!
|
|
14
|
+
|
|
15
|
+
def initialize level=nil
|
|
16
|
+
level ||= ENV["SUP_LOG_LEVEL"] || "info"
|
|
17
|
+
self.level = level
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@buf = StringIO.new
|
|
20
|
+
@sinks = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def level; LEVELS[@level] end
|
|
24
|
+
def level=(level); @level = LEVELS.index(level) || raise(ArgumentError, "invalid log level #{level.inspect}: should be one of #{LEVELS * ', '}"); end
|
|
25
|
+
|
|
26
|
+
def add_sink s, copy_current=true
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@sinks << s
|
|
29
|
+
s << @buf.string if copy_current
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def remove_sink s; @mutex.synchronize { @sinks.delete s } end
|
|
34
|
+
def remove_all_sinks!; @mutex.synchronize { @sinks.clear } end
|
|
35
|
+
def clear!; @mutex.synchronize { @buf = StringIO.new } end
|
|
36
|
+
|
|
37
|
+
LEVELS.each_with_index do |l, method_level|
|
|
38
|
+
define_method(l) do |s|
|
|
39
|
+
if method_level >= @level
|
|
40
|
+
send_message format_message(l, Time.now, s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
## send a message regardless of the current logging level
|
|
46
|
+
def force_message m; send_message format_message(nil, Time.now, m) end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
## level can be nil!
|
|
51
|
+
def format_message level, time, msg
|
|
52
|
+
prefix = case level
|
|
53
|
+
when "warn"; "WARNING: "
|
|
54
|
+
when "error"; "ERROR: "
|
|
55
|
+
else ""
|
|
56
|
+
end
|
|
57
|
+
"[#{time.to_s}] #{prefix}#{msg.rstrip}\n"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
## actually distribute the message
|
|
61
|
+
def send_message m
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@sinks.each do |sink|
|
|
64
|
+
sink << m
|
|
65
|
+
sink.flush if sink.respond_to?(:flush) and level == "debug"
|
|
66
|
+
end
|
|
67
|
+
@buf << m
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
## include me to have top-level #debug, #info, etc. methods.
|
|
73
|
+
module LogsStuff
|
|
74
|
+
Logger::LEVELS.each { |l| define_method(l) { |s| Logger.instance.send(l, s) } }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# TODO: this is ugly. It's better to have a application singleton passed
|
|
2
|
+
# down to lower level components instead of including logging methods in
|
|
3
|
+
# class `Object'
|
|
4
|
+
#
|
|
5
|
+
# For now this is what we have to do.
|
|
6
|
+
require "sup/logger"
|
|
7
|
+
Redwood::Logger.init.add_sink $stderr
|
|
8
|
+
class Object
|
|
9
|
+
include Redwood::LogsStuff
|
|
10
|
+
end
|
data/lib/sup/maildir.rb
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
require 'uri'
|
|
2
|
+
require 'set'
|
|
3
|
+
|
|
4
|
+
module Redwood
|
|
5
|
+
|
|
6
|
+
class Maildir < Source
|
|
7
|
+
include SerializeLabelsNicely
|
|
8
|
+
MYHOSTNAME = Socket.gethostname
|
|
9
|
+
|
|
10
|
+
## remind me never to use inheritance again.
|
|
11
|
+
yaml_properties :uri, :usual, :archived, :sync_back, :id, :labels
|
|
12
|
+
def initialize uri, usual=true, archived=false, sync_back=true, id=nil, labels=[]
|
|
13
|
+
super uri, usual, archived, id
|
|
14
|
+
@expanded_uri = Source.expand_filesystem_uri(uri)
|
|
15
|
+
uri = URI(@expanded_uri)
|
|
16
|
+
|
|
17
|
+
raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir"
|
|
18
|
+
raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" if uri.host
|
|
19
|
+
raise ArgumentError, "maildir URI must have a path component" unless uri.path
|
|
20
|
+
|
|
21
|
+
@sync_back = sync_back
|
|
22
|
+
# sync by default if not specified
|
|
23
|
+
@sync_back = true if @sync_back.nil?
|
|
24
|
+
|
|
25
|
+
@dir = uri.path
|
|
26
|
+
@labels = Set.new(labels || [])
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
@ctimes = { 'cur' => Time.at(0), 'new' => Time.at(0) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def file_path; @dir end
|
|
32
|
+
def self.suggest_labels_for path; [] end
|
|
33
|
+
def is_source_for? uri; super || (uri == @expanded_uri); end
|
|
34
|
+
|
|
35
|
+
def supported_labels?
|
|
36
|
+
[:draft, :starred, :forwarded, :replied, :unread, :deleted]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sync_back_enabled?
|
|
40
|
+
@sync_back
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def store_message date, from_email, &block
|
|
44
|
+
stored = false
|
|
45
|
+
new_fn = new_maildir_basefn + ':2,S'
|
|
46
|
+
Dir.chdir(@dir) do |d|
|
|
47
|
+
tmp_path = File.join(@dir, 'tmp', new_fn)
|
|
48
|
+
new_path = File.join(@dir, 'new', new_fn)
|
|
49
|
+
begin
|
|
50
|
+
sleep 2 if File.stat(tmp_path)
|
|
51
|
+
|
|
52
|
+
File.stat(tmp_path)
|
|
53
|
+
rescue Errno::ENOENT #this is what we want.
|
|
54
|
+
begin
|
|
55
|
+
File.open(tmp_path, 'wb') do |f|
|
|
56
|
+
yield f #provide a writable interface for the caller
|
|
57
|
+
f.fsync
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
File.safe_link tmp_path, new_path
|
|
61
|
+
stored = true
|
|
62
|
+
ensure
|
|
63
|
+
File.unlink tmp_path if File.exists? tmp_path
|
|
64
|
+
end
|
|
65
|
+
end #rescue Errno...
|
|
66
|
+
end #Dir.chdir
|
|
67
|
+
|
|
68
|
+
stored
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def each_raw_message_line id
|
|
72
|
+
with_file_for(id) do |f|
|
|
73
|
+
until f.eof?
|
|
74
|
+
yield f.gets
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def load_header id
|
|
80
|
+
with_file_for(id) { |f| parse_raw_email_header f }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def load_message id
|
|
84
|
+
with_file_for(id) { |f| RMail::Parser.read f }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sync_back id, labels
|
|
88
|
+
synchronize do
|
|
89
|
+
debug "syncing back maildir message #{id} with flags #{labels.to_a}"
|
|
90
|
+
flags = maildir_reconcile_flags id, labels
|
|
91
|
+
maildir_mark_file id, flags
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def raw_header id
|
|
96
|
+
ret = ""
|
|
97
|
+
with_file_for(id) do |f|
|
|
98
|
+
until f.eof? || (l = f.gets) =~ /^$/
|
|
99
|
+
ret += l
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
ret
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def raw_message id
|
|
106
|
+
with_file_for(id) { |f| f.read }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
## XXX use less memory
|
|
110
|
+
def poll
|
|
111
|
+
added = []
|
|
112
|
+
deleted = []
|
|
113
|
+
updated = []
|
|
114
|
+
@ctimes.each do |d,prev_ctime|
|
|
115
|
+
subdir = File.join @dir, d
|
|
116
|
+
debug "polling maildir #{subdir}"
|
|
117
|
+
raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir
|
|
118
|
+
ctime = File.ctime subdir
|
|
119
|
+
next if prev_ctime >= ctime
|
|
120
|
+
@ctimes[d] = ctime
|
|
121
|
+
|
|
122
|
+
old_ids = benchmark(:maildir_read_index) { Index.instance.enum_for(:each_source_info, self.id, "#{d}/").to_a }
|
|
123
|
+
new_ids = benchmark(:maildir_read_dir) { Dir.glob("#{subdir}/*").map { |x| File.join(d,File.basename(x)) }.sort }
|
|
124
|
+
added += new_ids - old_ids
|
|
125
|
+
deleted += old_ids - new_ids
|
|
126
|
+
debug "#{old_ids.size} in index, #{new_ids.size} in filesystem"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
## find updated mails by checking if an id is in both added and
|
|
130
|
+
## deleted arrays, meaning that its flags changed or that it has
|
|
131
|
+
## been moved, these ids need to be removed from added and deleted
|
|
132
|
+
add_to_delete = del_to_delete = []
|
|
133
|
+
map = Hash.new { |hash, key| hash[key] = [] }
|
|
134
|
+
deleted.each do |id_del|
|
|
135
|
+
map[maildir_data(id_del)[0]].push id_del
|
|
136
|
+
end
|
|
137
|
+
added.each do |id_add|
|
|
138
|
+
map[maildir_data(id_add)[0]].each do |id_del|
|
|
139
|
+
updated.push [ id_del, id_add ]
|
|
140
|
+
add_to_delete.push id_add
|
|
141
|
+
del_to_delete.push id_del
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
added -= add_to_delete
|
|
145
|
+
deleted -= del_to_delete
|
|
146
|
+
debug "#{added.size} added, #{deleted.size} deleted, #{updated.size} updated"
|
|
147
|
+
total_size = added.size+deleted.size+updated.size
|
|
148
|
+
|
|
149
|
+
added.each_with_index do |id,i|
|
|
150
|
+
yield :add,
|
|
151
|
+
:info => id,
|
|
152
|
+
:labels => @labels + maildir_labels(id) + [:inbox],
|
|
153
|
+
:progress => i.to_f/total_size
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
deleted.each_with_index do |id,i|
|
|
157
|
+
yield :delete,
|
|
158
|
+
:info => id,
|
|
159
|
+
:progress => (i.to_f+added.size)/total_size
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
updated.each_with_index do |id,i|
|
|
163
|
+
yield :update,
|
|
164
|
+
:old_info => id[0],
|
|
165
|
+
:new_info => id[1],
|
|
166
|
+
:labels => @labels + maildir_labels(id[1]),
|
|
167
|
+
:progress => (i.to_f+added.size+deleted.size)/total_size
|
|
168
|
+
end
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def labels? id
|
|
173
|
+
maildir_labels id
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def maildir_labels id
|
|
177
|
+
(seen?(id) ? [] : [:unread]) +
|
|
178
|
+
(trashed?(id) ? [:deleted] : []) +
|
|
179
|
+
(flagged?(id) ? [:starred] : []) +
|
|
180
|
+
(passed?(id) ? [:forwarded] : []) +
|
|
181
|
+
(replied?(id) ? [:replied] : []) +
|
|
182
|
+
(draft?(id) ? [:draft] : [])
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def draft? id; maildir_data(id)[2].include? "D"; end
|
|
186
|
+
def flagged? id; maildir_data(id)[2].include? "F"; end
|
|
187
|
+
def passed? id; maildir_data(id)[2].include? "P"; end
|
|
188
|
+
def replied? id; maildir_data(id)[2].include? "R"; end
|
|
189
|
+
def seen? id; maildir_data(id)[2].include? "S"; end
|
|
190
|
+
def trashed? id; maildir_data(id)[2].include? "T"; end
|
|
191
|
+
|
|
192
|
+
def valid? id
|
|
193
|
+
File.exists? File.join(@dir, id)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def new_maildir_basefn
|
|
199
|
+
Kernel::srand()
|
|
200
|
+
"#{Time.now.to_i.to_s}.#{$$}#{Kernel.rand(1000000)}.#{MYHOSTNAME}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def with_file_for id
|
|
204
|
+
fn = File.join(@dir, id)
|
|
205
|
+
begin
|
|
206
|
+
File.open(fn, 'rb') { |f| yield f }
|
|
207
|
+
rescue SystemCallError, IOError => e
|
|
208
|
+
raise FatalSourceError, "Problem reading file for id #{id.inspect}: #{fn.inspect}: #{e.message}."
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def maildir_data id
|
|
213
|
+
id = File.basename id
|
|
214
|
+
# Flags we recognize are DFPRST
|
|
215
|
+
id =~ %r{^([^:]+):([12]),([A-Za-z]*)$}
|
|
216
|
+
[($1 || id), ($2 || "2"), ($3 || "")]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def maildir_reconcile_flags id, labels
|
|
220
|
+
new_flags = Set.new( maildir_data(id)[2].each_char )
|
|
221
|
+
|
|
222
|
+
# Set flags based on labels for the six flags we recognize
|
|
223
|
+
if labels.member? :draft then new_flags.add?( "D" ) else new_flags.delete?( "D" ) end
|
|
224
|
+
if labels.member? :starred then new_flags.add?( "F" ) else new_flags.delete?( "F" ) end
|
|
225
|
+
if labels.member? :forwarded then new_flags.add?( "P" ) else new_flags.delete?( "P" ) end
|
|
226
|
+
if labels.member? :replied then new_flags.add?( "R" ) else new_flags.delete?( "R" ) end
|
|
227
|
+
if not labels.member? :unread then new_flags.add?( "S" ) else new_flags.delete?( "S" ) end
|
|
228
|
+
if labels.member? :deleted or labels.member? :killed then new_flags.add?( "T" ) else new_flags.delete?( "T" ) end
|
|
229
|
+
|
|
230
|
+
## Flags must be stored in ASCII order according to Maildir
|
|
231
|
+
## documentation
|
|
232
|
+
new_flags.to_a.sort.join
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def maildir_mark_file orig_path, flags
|
|
236
|
+
@mutex.synchronize do
|
|
237
|
+
new_base = (flags.include?("S")) ? "cur" : "new"
|
|
238
|
+
md_base, md_ver, md_flags = maildir_data orig_path
|
|
239
|
+
|
|
240
|
+
return if md_flags == flags
|
|
241
|
+
|
|
242
|
+
new_loc = File.join new_base, "#{md_base}:#{md_ver},#{flags}"
|
|
243
|
+
orig_path = File.join @dir, orig_path
|
|
244
|
+
new_path = File.join @dir, new_loc
|
|
245
|
+
tmp_path = File.join @dir, "tmp", "#{md_base}:#{md_ver},#{flags}"
|
|
246
|
+
|
|
247
|
+
File.safe_link orig_path, tmp_path
|
|
248
|
+
File.unlink orig_path
|
|
249
|
+
File.safe_link tmp_path, new_path
|
|
250
|
+
File.unlink tmp_path
|
|
251
|
+
|
|
252
|
+
new_loc
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
end
|