sup 0.19.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/lib/sup/colormap.rb
ADDED
@@ -0,0 +1,239 @@
|
|
1
|
+
module Ncurses
|
2
|
+
COLOR_DEFAULT = -1
|
3
|
+
|
4
|
+
NUM_COLORS = `tput colors`.to_i
|
5
|
+
MAX_PAIRS = `tput pairs`.to_i
|
6
|
+
|
7
|
+
def self.color! name, value
|
8
|
+
const_set "COLOR_#{name.to_s.upcase}", value
|
9
|
+
end
|
10
|
+
|
11
|
+
## numeric colors
|
12
|
+
Ncurses::NUM_COLORS.times { |x| color! x, x }
|
13
|
+
|
14
|
+
if Ncurses::NUM_COLORS == 256
|
15
|
+
## xterm 6x6x6 color cube
|
16
|
+
6.times { |x| 6.times { |y| 6.times { |z| color! "c#{x}#{y}#{z}", 16 + z + 6*y + 36*x } } }
|
17
|
+
|
18
|
+
## xterm 24-shade grayscale
|
19
|
+
24.times { |x| color! "g#{x}", (16+6*6*6) + x }
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
module Redwood
|
24
|
+
|
25
|
+
class Colormap
|
26
|
+
@@instance = nil
|
27
|
+
|
28
|
+
DEFAULT_COLORS = {
|
29
|
+
:text => { :fg => "white", :bg => "black" },
|
30
|
+
:status => { :fg => "white", :bg => "blue", :attrs => ["bold"] },
|
31
|
+
:index_old => { :fg => "white", :bg => "default" },
|
32
|
+
:index_new => { :fg => "white", :bg => "default", :attrs => ["bold"] },
|
33
|
+
:index_starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
|
34
|
+
:index_draft => { :fg => "red", :bg => "default", :attrs => ["bold"] },
|
35
|
+
:labellist_old => { :fg => "white", :bg => "default" },
|
36
|
+
:labellist_new => { :fg => "white", :bg => "default", :attrs => ["bold"] },
|
37
|
+
:twiddle => { :fg => "blue", :bg => "default" },
|
38
|
+
:label => { :fg => "yellow", :bg => "default" },
|
39
|
+
:message_patina => { :fg => "black", :bg => "green" },
|
40
|
+
:alternate_patina => { :fg => "black", :bg => "blue" },
|
41
|
+
:missing_message => { :fg => "black", :bg => "red" },
|
42
|
+
:attachment => { :fg => "cyan", :bg => "default" },
|
43
|
+
:cryptosig_valid => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
|
44
|
+
:cryptosig_valid_untrusted => { :fg => "yellow", :bg => "blue", :attrs => ["bold"] },
|
45
|
+
:cryptosig_unknown => { :fg => "cyan", :bg => "default" },
|
46
|
+
:cryptosig_invalid => { :fg => "yellow", :bg => "red", :attrs => ["bold"] },
|
47
|
+
:generic_notice_patina => { :fg => "cyan", :bg => "default" },
|
48
|
+
:quote_patina => { :fg => "yellow", :bg => "default" },
|
49
|
+
:sig_patina => { :fg => "yellow", :bg => "default" },
|
50
|
+
:quote => { :fg => "yellow", :bg => "default" },
|
51
|
+
:sig => { :fg => "yellow", :bg => "default" },
|
52
|
+
:to_me => { :fg => "green", :bg => "default" },
|
53
|
+
:with_attachment => { :fg => "green", :bg => "default" },
|
54
|
+
:starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
|
55
|
+
:starred_patina => { :fg => "yellow", :bg => "green", :attrs => ["bold"] },
|
56
|
+
:alternate_starred_patina => { :fg => "yellow", :bg => "blue", :attrs => ["bold"] },
|
57
|
+
:snippet => { :fg => "cyan", :bg => "default" },
|
58
|
+
:option => { :fg => "white", :bg => "default" },
|
59
|
+
:tagged => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
|
60
|
+
:draft_notification => { :fg => "red", :bg => "default", :attrs => ["bold"] },
|
61
|
+
:completion_character => { :fg => "white", :bg => "default", :attrs => ["bold"] },
|
62
|
+
:horizontal_selector_selected => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
|
63
|
+
:horizontal_selector_unselected => { :fg => "cyan", :bg => "default" },
|
64
|
+
:search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] },
|
65
|
+
:system_buf => { :fg => "blue", :bg => "default" },
|
66
|
+
:regular_buf => { :fg => "white", :bg => "default" },
|
67
|
+
:modified_buffer => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
|
68
|
+
:date => { :fg => "white", :bg => "default"},
|
69
|
+
:size_widget => { :fg => "white", :bg => "default"},
|
70
|
+
}
|
71
|
+
|
72
|
+
def initialize
|
73
|
+
raise "only one instance can be created" if @@instance
|
74
|
+
@@instance = self
|
75
|
+
@color_pairs = {[Ncurses::COLOR_WHITE, Ncurses::COLOR_BLACK] => 0}
|
76
|
+
@users = []
|
77
|
+
@next_id = 0
|
78
|
+
reset
|
79
|
+
yield self if block_given?
|
80
|
+
end
|
81
|
+
|
82
|
+
def reset
|
83
|
+
@entries = {}
|
84
|
+
@highlights = { :none => highlight_sym(:none)}
|
85
|
+
@entries[highlight_sym(:none)] = highlight_for(Ncurses::COLOR_WHITE,
|
86
|
+
Ncurses::COLOR_BLACK,
|
87
|
+
[]) + [nil]
|
88
|
+
end
|
89
|
+
|
90
|
+
def add sym, fg, bg, attr=nil, highlight=nil
|
91
|
+
raise ArgumentError, "color for #{sym} already defined" if @entries.member? sym
|
92
|
+
raise ArgumentError, "color '#{fg}' unknown" unless (-1...Ncurses::NUM_COLORS).include? fg
|
93
|
+
raise ArgumentError, "color '#{bg}' unknown" unless (-1...Ncurses::NUM_COLORS).include? bg
|
94
|
+
attrs = [attr].flatten.compact
|
95
|
+
|
96
|
+
@entries[sym] = [fg, bg, attrs, nil]
|
97
|
+
|
98
|
+
if not highlight
|
99
|
+
highlight = highlight_sym(sym)
|
100
|
+
@entries[highlight] = highlight_for(fg, bg, attrs) + [nil]
|
101
|
+
end
|
102
|
+
|
103
|
+
@highlights[sym] = highlight
|
104
|
+
end
|
105
|
+
|
106
|
+
def highlight_sym sym
|
107
|
+
"#{sym}_highlight".intern
|
108
|
+
end
|
109
|
+
|
110
|
+
def highlight_for fg, bg, attrs
|
111
|
+
hfg =
|
112
|
+
case fg
|
113
|
+
when Ncurses::COLOR_BLUE
|
114
|
+
Ncurses::COLOR_WHITE
|
115
|
+
when Ncurses::COLOR_YELLOW, Ncurses::COLOR_GREEN
|
116
|
+
fg
|
117
|
+
else
|
118
|
+
Ncurses::COLOR_BLACK
|
119
|
+
end
|
120
|
+
|
121
|
+
hbg =
|
122
|
+
case bg
|
123
|
+
when Ncurses::COLOR_CYAN
|
124
|
+
Ncurses::COLOR_YELLOW
|
125
|
+
when Ncurses::COLOR_YELLOW
|
126
|
+
Ncurses::COLOR_BLUE
|
127
|
+
else
|
128
|
+
Ncurses::COLOR_CYAN
|
129
|
+
end
|
130
|
+
|
131
|
+
attrs =
|
132
|
+
if fg == Ncurses::COLOR_WHITE && attrs.include?(Ncurses::A_BOLD)
|
133
|
+
[Ncurses::A_BOLD]
|
134
|
+
else
|
135
|
+
case hfg
|
136
|
+
when Ncurses::COLOR_BLACK
|
137
|
+
[]
|
138
|
+
else
|
139
|
+
[Ncurses::A_BOLD]
|
140
|
+
end
|
141
|
+
end
|
142
|
+
[hfg, hbg, attrs]
|
143
|
+
end
|
144
|
+
|
145
|
+
def color_for sym, highlight=false
|
146
|
+
sym = @highlights[sym] if highlight
|
147
|
+
return Ncurses::COLOR_BLACK if sym == :none
|
148
|
+
raise ArgumentError, "undefined color #{sym}" unless @entries.member? sym
|
149
|
+
|
150
|
+
## if this color is cached, return it
|
151
|
+
fg, bg, attrs, color = @entries[sym]
|
152
|
+
return color if color
|
153
|
+
|
154
|
+
if(cp = @color_pairs[[fg, bg]])
|
155
|
+
## nothing
|
156
|
+
else ## need to get a new colorpair
|
157
|
+
@next_id = (@next_id + 1) % Ncurses::MAX_PAIRS
|
158
|
+
@next_id += 1 if @next_id == 0 # 0 is always white on black
|
159
|
+
id = @next_id
|
160
|
+
debug "colormap: for color #{sym}, using id #{id} -> #{fg}, #{bg}"
|
161
|
+
Ncurses.init_pair id, fg, bg or raise ArgumentError,
|
162
|
+
"couldn't initialize curses color pair #{fg}, #{bg} (key #{id})"
|
163
|
+
|
164
|
+
cp = @color_pairs[[fg, bg]] = Ncurses.COLOR_PAIR(id)
|
165
|
+
## delete the old mapping, if it exists
|
166
|
+
if @users[cp]
|
167
|
+
@users[cp].each do |usym|
|
168
|
+
warn "dropping color #{usym} (#{id})"
|
169
|
+
@entries[usym][3] = nil
|
170
|
+
end
|
171
|
+
@users[cp] = []
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
## by now we have a color pair
|
176
|
+
color = attrs.inject(cp) { |color, attr| color | attr }
|
177
|
+
@entries[sym][3] = color # fill the cache
|
178
|
+
(@users[cp] ||= []) << sym # record entry as a user of that color pair
|
179
|
+
color
|
180
|
+
end
|
181
|
+
|
182
|
+
def sym_is_defined sym
|
183
|
+
return sym if @entries.member? sym
|
184
|
+
end
|
185
|
+
|
186
|
+
## Try to use the user defined colors, in case of an error fall back
|
187
|
+
## to the default ones.
|
188
|
+
def populate_colormap
|
189
|
+
user_colors = if File.exists? Redwood::COLOR_FN
|
190
|
+
debug "loading user colors from #{Redwood::COLOR_FN}"
|
191
|
+
Redwood::load_yaml_obj Redwood::COLOR_FN
|
192
|
+
end
|
193
|
+
|
194
|
+
## Set attachment sybmol to sane default for existing colorschemes
|
195
|
+
if user_colors and user_colors.has_key? :to_me
|
196
|
+
user_colors[:with_attachment] = user_colors[:to_me] unless user_colors.has_key? :with_attachment
|
197
|
+
end
|
198
|
+
|
199
|
+
Colormap::DEFAULT_COLORS.merge(user_colors||{}).each_pair do |k, v|
|
200
|
+
fg = begin
|
201
|
+
Ncurses.const_get "COLOR_#{v[:fg].to_s.upcase}"
|
202
|
+
rescue NameError
|
203
|
+
warn "there is no color named \"#{v[:fg]}\""
|
204
|
+
Ncurses::COLOR_GREEN
|
205
|
+
end
|
206
|
+
|
207
|
+
bg = begin
|
208
|
+
Ncurses.const_get "COLOR_#{v[:bg].to_s.upcase}"
|
209
|
+
rescue NameError
|
210
|
+
warn "there is no color named \"#{v[:bg]}\""
|
211
|
+
Ncurses::COLOR_RED
|
212
|
+
end
|
213
|
+
|
214
|
+
attrs = (v[:attrs]||[]).map do |a|
|
215
|
+
begin
|
216
|
+
Ncurses.const_get "A_#{a.upcase}"
|
217
|
+
rescue NameError
|
218
|
+
warn "there is no attribute named \"#{a}\", using fallback."
|
219
|
+
nil
|
220
|
+
end
|
221
|
+
end.compact
|
222
|
+
|
223
|
+
highlight_symbol = v[:highlight] ? :"#{v[:highlight]}_color" : nil
|
224
|
+
|
225
|
+
symbol = (k.to_s + "_color").to_sym
|
226
|
+
add symbol, fg, bg, attrs, highlight_symbol
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
def self.instance; @@instance; end
|
231
|
+
def self.method_missing meth, *a
|
232
|
+
Colormap.new unless @@instance
|
233
|
+
@@instance.send meth, *a
|
234
|
+
end
|
235
|
+
# Performance shortcut
|
236
|
+
def self.color_for *a; @@instance.color_for *a; end
|
237
|
+
end
|
238
|
+
|
239
|
+
end
|
data/lib/sup/contact.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Redwood
|
4
|
+
|
5
|
+
class ContactManager
|
6
|
+
include Redwood::Singleton
|
7
|
+
|
8
|
+
def initialize fn
|
9
|
+
@fn = fn
|
10
|
+
|
11
|
+
## maintain the mapping between people and aliases. for contacts without
|
12
|
+
## aliases, there will be no @a2p entry, so @p2a.keys should be treated
|
13
|
+
## as the canonical list of contacts.
|
14
|
+
|
15
|
+
@p2a = {} # person to alias
|
16
|
+
@a2p = {} # alias to person
|
17
|
+
@e2p = {} # email to person
|
18
|
+
|
19
|
+
if File.exists? fn
|
20
|
+
IO.foreach(fn) do |l|
|
21
|
+
l =~ /^([^:]*): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}"
|
22
|
+
aalias, addr = $1, $2
|
23
|
+
update_alias Person.from_address(addr), aalias
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def contacts; @p2a.keys end
|
29
|
+
def contacts_with_aliases; @a2p.values.uniq end
|
30
|
+
|
31
|
+
def update_alias person, aalias=nil
|
32
|
+
old_aalias = @p2a[person]
|
33
|
+
if(old_aalias != nil and old_aalias != "") # remove old alias
|
34
|
+
@a2p.delete old_aalias
|
35
|
+
@e2p.delete person.email
|
36
|
+
end
|
37
|
+
@p2a[person] = aalias
|
38
|
+
unless aalias.nil? || aalias.empty?
|
39
|
+
@a2p[aalias] = person
|
40
|
+
@e2p[person.email] = person
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
## this may not actually be called anywhere, since we still keep contacts
|
45
|
+
## around without aliases to override any fullname changes.
|
46
|
+
def drop_contact person
|
47
|
+
aalias = @p2a[person]
|
48
|
+
@p2a.delete person
|
49
|
+
@e2p.delete person.email
|
50
|
+
@a2p.delete aalias if aalias
|
51
|
+
end
|
52
|
+
|
53
|
+
def contact_for aalias; @a2p[aalias] end
|
54
|
+
def alias_for person; @p2a[person] end
|
55
|
+
def person_for email; @e2p[email] end
|
56
|
+
def is_aliased_contact? person; !@p2a[person].nil? end
|
57
|
+
|
58
|
+
def save
|
59
|
+
File.open(@fn, "w:UTF-8") do |f|
|
60
|
+
@p2a.sort_by { |(p, a)| [p.full_address, a] }.each do |(p, a)|
|
61
|
+
f.puts "#{a || ''}: #{p.full_address}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
data/lib/sup/crypto.rb
ADDED
@@ -0,0 +1,461 @@
|
|
1
|
+
begin
|
2
|
+
require 'gpgme'
|
3
|
+
rescue LoadError
|
4
|
+
end
|
5
|
+
|
6
|
+
module Redwood
|
7
|
+
|
8
|
+
class CryptoManager
|
9
|
+
include Redwood::Singleton
|
10
|
+
|
11
|
+
class Error < StandardError; end
|
12
|
+
|
13
|
+
OUTGOING_MESSAGE_OPERATIONS = OrderedHash.new(
|
14
|
+
[:sign, "Sign"],
|
15
|
+
[:sign_and_encrypt, "Sign and encrypt"],
|
16
|
+
[:encrypt, "Encrypt only"]
|
17
|
+
)
|
18
|
+
|
19
|
+
HookManager.register "gpg-options", <<EOS
|
20
|
+
Runs before gpg is called, allowing you to modify the options (most
|
21
|
+
likely you would want to add something to certain commands, like
|
22
|
+
{:always_trust => true} to encrypting a message, but who knows).
|
23
|
+
|
24
|
+
Variables:
|
25
|
+
operation: what operation will be done ("sign", "encrypt", "decrypt" or "verify")
|
26
|
+
options: a dictionary of values to be passed to GPGME
|
27
|
+
|
28
|
+
Return value: a dictionary to be passed to GPGME
|
29
|
+
EOS
|
30
|
+
|
31
|
+
HookManager.register "sig-output", <<EOS
|
32
|
+
Runs when the signature output is being generated, allowing you to
|
33
|
+
add extra information to your signatures if you want.
|
34
|
+
|
35
|
+
Variables:
|
36
|
+
signature: the signature object (class is GPGME::Signature)
|
37
|
+
from_key: the key that generated the signature (class is GPGME::Key)
|
38
|
+
|
39
|
+
Return value: an array of lines of output
|
40
|
+
EOS
|
41
|
+
|
42
|
+
HookManager.register "gpg-expand-keys", <<EOS
|
43
|
+
Runs when the list of encryption recipients is created, allowing you to
|
44
|
+
replace a recipient with one or more GPGME recipients. For example, you could
|
45
|
+
replace the email address of a mailing list with the key IDs that belong to
|
46
|
+
the recipients of that list. This is essentially what GPG groups do, which
|
47
|
+
are not supported by GPGME.
|
48
|
+
|
49
|
+
Variables:
|
50
|
+
recipients: an array of recipients of the current email
|
51
|
+
|
52
|
+
Return value: an array of recipients (email address or GPG key ID) to encrypt
|
53
|
+
the email for
|
54
|
+
EOS
|
55
|
+
|
56
|
+
def initialize
|
57
|
+
@mutex = Mutex.new
|
58
|
+
|
59
|
+
@not_working_reason = nil
|
60
|
+
|
61
|
+
# test if the gpgme gem is available
|
62
|
+
@gpgme_present =
|
63
|
+
begin
|
64
|
+
begin
|
65
|
+
begin
|
66
|
+
GPGME.check_version({:protocol => GPGME::PROTOCOL_OpenPGP})
|
67
|
+
rescue TypeError
|
68
|
+
GPGME.check_version(nil)
|
69
|
+
end
|
70
|
+
true
|
71
|
+
rescue GPGME::Error
|
72
|
+
false
|
73
|
+
rescue ArgumentError
|
74
|
+
# gpgme 2.0.0 raises this due to the hash->string conversion
|
75
|
+
false
|
76
|
+
end
|
77
|
+
rescue NameError
|
78
|
+
false
|
79
|
+
end
|
80
|
+
|
81
|
+
unless @gpgme_present
|
82
|
+
@not_working_reason = ['gpgme gem not present',
|
83
|
+
'Install the gpgme gem in order to use signed and encrypted emails']
|
84
|
+
return
|
85
|
+
end
|
86
|
+
|
87
|
+
# if gpg2 is available, it will start gpg-agent if required
|
88
|
+
if (bin = `which gpg2`.chomp) =~ /\S/
|
89
|
+
if GPGME.respond_to?('set_engine_info')
|
90
|
+
GPGME.set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
|
91
|
+
else
|
92
|
+
GPGME.gpgme_set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
|
93
|
+
end
|
94
|
+
else
|
95
|
+
# check if the gpg-options hook uses the passphrase_callback
|
96
|
+
# if it doesn't then check if gpg agent is present
|
97
|
+
gpg_opts = HookManager.run("gpg-options",
|
98
|
+
{:operation => "sign", :options => {}}) || {}
|
99
|
+
if gpg_opts[:passphrase_callback].nil?
|
100
|
+
if ENV['GPG_AGENT_INFO'].nil?
|
101
|
+
@not_working_reason = ["Environment variable 'GPG_AGENT_INFO' not set, is gpg-agent running?",
|
102
|
+
"If gpg-agent is running, try $ export `cat ~/.gpg-agent-info`"]
|
103
|
+
return
|
104
|
+
end
|
105
|
+
|
106
|
+
gpg_agent_socket_file = ENV['GPG_AGENT_INFO'].split(':')[0]
|
107
|
+
unless File.exist?(gpg_agent_socket_file)
|
108
|
+
@not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} does not exist"]
|
109
|
+
return
|
110
|
+
end
|
111
|
+
|
112
|
+
s = File.stat(gpg_agent_socket_file)
|
113
|
+
unless s.socket?
|
114
|
+
@not_working_reason = ["gpg-agent socket file #{gpg_agent_socket_file} is not a socket"]
|
115
|
+
return
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def have_crypto?; @not_working_reason.nil? end
|
122
|
+
def not_working_reason; @not_working_reason end
|
123
|
+
|
124
|
+
def sign from, to, payload
|
125
|
+
return unknown_status(@not_working_reason) unless @not_working_reason.nil?
|
126
|
+
|
127
|
+
gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
|
128
|
+
gpg_opts.merge!(gen_sign_user_opts(from))
|
129
|
+
gpg_opts = HookManager.run("gpg-options",
|
130
|
+
{:operation => "sign", :options => gpg_opts}) || gpg_opts
|
131
|
+
begin
|
132
|
+
if GPGME.respond_to?('detach_sign')
|
133
|
+
sig = GPGME.detach_sign(format_payload(payload), gpg_opts)
|
134
|
+
else
|
135
|
+
crypto = GPGME::Crypto.new
|
136
|
+
gpg_opts[:mode] = GPGME::SIG_MODE_DETACH
|
137
|
+
sig = crypto.sign(format_payload(payload), gpg_opts).read
|
138
|
+
end
|
139
|
+
rescue GPGME::Error => exc
|
140
|
+
raise Error, gpgme_exc_msg(exc.message)
|
141
|
+
end
|
142
|
+
|
143
|
+
# if the key (or gpg-agent) is not available GPGME does not complain
|
144
|
+
# but just returns a zero length string. Let's catch that
|
145
|
+
if sig.length == 0
|
146
|
+
raise Error, gpgme_exc_msg("GPG failed to generate signature: check that gpg-agent is running and your key is available.")
|
147
|
+
end
|
148
|
+
|
149
|
+
envelope = RMail::Message.new
|
150
|
+
envelope.header["Content-Type"] = 'multipart/signed; protocol=application/pgp-signature'
|
151
|
+
|
152
|
+
envelope.add_part payload
|
153
|
+
signature = RMail::Message.make_attachment sig, "application/pgp-signature", nil, "signature.asc"
|
154
|
+
envelope.add_part signature
|
155
|
+
envelope
|
156
|
+
end
|
157
|
+
|
158
|
+
def encrypt from, to, payload, sign=false
|
159
|
+
return unknown_status(@not_working_reason) unless @not_working_reason.nil?
|
160
|
+
|
161
|
+
gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
|
162
|
+
if sign
|
163
|
+
gpg_opts.merge!(gen_sign_user_opts(from))
|
164
|
+
gpg_opts.merge!({:sign => true})
|
165
|
+
end
|
166
|
+
gpg_opts = HookManager.run("gpg-options",
|
167
|
+
{:operation => "encrypt", :options => gpg_opts}) || gpg_opts
|
168
|
+
recipients = to + [from]
|
169
|
+
recipients = HookManager.run("gpg-expand-keys", { :recipients => recipients }) || recipients
|
170
|
+
begin
|
171
|
+
if GPGME.respond_to?('encrypt')
|
172
|
+
cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts)
|
173
|
+
else
|
174
|
+
crypto = GPGME::Crypto.new
|
175
|
+
gpg_opts[:recipients] = recipients
|
176
|
+
cipher = crypto.encrypt(format_payload(payload), gpg_opts).read
|
177
|
+
end
|
178
|
+
rescue GPGME::Error => exc
|
179
|
+
raise Error, gpgme_exc_msg(exc.message)
|
180
|
+
end
|
181
|
+
|
182
|
+
# if the key (or gpg-agent) is not available GPGME does not complain
|
183
|
+
# but just returns a zero length string. Let's catch that
|
184
|
+
if cipher.length == 0
|
185
|
+
raise Error, gpgme_exc_msg("GPG failed to generate cipher text: check that gpg-agent is running and your key is available.")
|
186
|
+
end
|
187
|
+
|
188
|
+
encrypted_payload = RMail::Message.new
|
189
|
+
encrypted_payload.header["Content-Type"] = "application/octet-stream"
|
190
|
+
encrypted_payload.header["Content-Disposition"] = 'inline; filename="msg.asc"'
|
191
|
+
encrypted_payload.body = cipher
|
192
|
+
|
193
|
+
control = RMail::Message.new
|
194
|
+
control.header["Content-Type"] = "application/pgp-encrypted"
|
195
|
+
control.header["Content-Disposition"] = "attachment"
|
196
|
+
control.body = "Version: 1\n"
|
197
|
+
|
198
|
+
envelope = RMail::Message.new
|
199
|
+
envelope.header["Content-Type"] = 'multipart/encrypted; protocol=application/pgp-encrypted'
|
200
|
+
|
201
|
+
envelope.add_part control
|
202
|
+
envelope.add_part encrypted_payload
|
203
|
+
envelope
|
204
|
+
end
|
205
|
+
|
206
|
+
def sign_and_encrypt from, to, payload
|
207
|
+
encrypt from, to, payload, true
|
208
|
+
end
|
209
|
+
|
210
|
+
def verified_ok? verify_result
|
211
|
+
valid = true
|
212
|
+
unknown = false
|
213
|
+
all_output_lines = []
|
214
|
+
all_trusted = true
|
215
|
+
|
216
|
+
verify_result.signatures.each do |signature|
|
217
|
+
output_lines, trusted = sig_output_lines signature
|
218
|
+
all_output_lines << output_lines
|
219
|
+
all_output_lines.flatten!
|
220
|
+
all_trusted &&= trusted
|
221
|
+
|
222
|
+
err_code = GPGME::gpgme_err_code(signature.status)
|
223
|
+
if err_code == GPGME::GPG_ERR_BAD_SIGNATURE
|
224
|
+
valid = false
|
225
|
+
elsif err_code != GPGME::GPG_ERR_NO_ERROR
|
226
|
+
valid = false
|
227
|
+
unknown = true
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
if valid || !unknown
|
232
|
+
summary_line = simplify_sig_line(verify_result.signatures[0].to_s, all_trusted)
|
233
|
+
end
|
234
|
+
|
235
|
+
if all_output_lines.length == 0
|
236
|
+
Chunk::CryptoNotice.new :valid, "Encrypted message wasn't signed", all_output_lines
|
237
|
+
elsif valid
|
238
|
+
if all_trusted
|
239
|
+
Chunk::CryptoNotice.new(:valid, summary_line, all_output_lines)
|
240
|
+
else
|
241
|
+
Chunk::CryptoNotice.new(:valid_untrusted, summary_line, all_output_lines)
|
242
|
+
end
|
243
|
+
elsif !unknown
|
244
|
+
Chunk::CryptoNotice.new(:invalid, summary_line, all_output_lines)
|
245
|
+
else
|
246
|
+
unknown_status all_output_lines
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
def verify payload, signature, detached=true # both RubyMail::Message objects
|
251
|
+
return unknown_status(@not_working_reason) unless @not_working_reason.nil?
|
252
|
+
|
253
|
+
gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
|
254
|
+
gpg_opts = HookManager.run("gpg-options",
|
255
|
+
{:operation => "verify", :options => gpg_opts}) || gpg_opts
|
256
|
+
ctx = GPGME::Ctx.new(gpg_opts)
|
257
|
+
sig_data = GPGME::Data.from_str signature.decode
|
258
|
+
if detached
|
259
|
+
signed_text_data = GPGME::Data.from_str(format_payload(payload))
|
260
|
+
plain_data = nil
|
261
|
+
else
|
262
|
+
signed_text_data = nil
|
263
|
+
if GPGME::Data.respond_to?('empty')
|
264
|
+
plain_data = GPGME::Data.empty
|
265
|
+
else
|
266
|
+
plain_data = GPGME::Data.empty!
|
267
|
+
end
|
268
|
+
end
|
269
|
+
begin
|
270
|
+
ctx.verify(sig_data, signed_text_data, plain_data)
|
271
|
+
rescue GPGME::Error => exc
|
272
|
+
return unknown_status [gpgme_exc_msg(exc.message)]
|
273
|
+
end
|
274
|
+
begin
|
275
|
+
self.verified_ok? ctx.verify_result
|
276
|
+
rescue ArgumentError => exc
|
277
|
+
return unknown_status [gpgme_exc_msg(exc.message)]
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
## returns decrypted_message, status, desc, lines
|
282
|
+
def decrypt payload, armor=false # a RubyMail::Message object
|
283
|
+
return unknown_status(@not_working_reason) unless @not_working_reason.nil?
|
284
|
+
|
285
|
+
gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP}
|
286
|
+
gpg_opts = HookManager.run("gpg-options",
|
287
|
+
{:operation => "decrypt", :options => gpg_opts}) || gpg_opts
|
288
|
+
ctx = GPGME::Ctx.new(gpg_opts)
|
289
|
+
cipher_data = GPGME::Data.from_str(format_payload(payload))
|
290
|
+
if GPGME::Data.respond_to?('empty')
|
291
|
+
plain_data = GPGME::Data.empty
|
292
|
+
else
|
293
|
+
plain_data = GPGME::Data.empty!
|
294
|
+
end
|
295
|
+
begin
|
296
|
+
ctx.decrypt_verify(cipher_data, plain_data)
|
297
|
+
rescue GPGME::Error => exc
|
298
|
+
return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", gpgme_exc_msg(exc.message))
|
299
|
+
end
|
300
|
+
begin
|
301
|
+
sig = self.verified_ok? ctx.verify_result
|
302
|
+
rescue ArgumentError => exc
|
303
|
+
sig = unknown_status [gpgme_exc_msg(exc.message)]
|
304
|
+
end
|
305
|
+
plain_data.seek(0, IO::SEEK_SET)
|
306
|
+
output = plain_data.read
|
307
|
+
output.transcode(Encoding::ASCII_8BIT, output.encoding)
|
308
|
+
|
309
|
+
## TODO: test to see if it is still necessary to do a 2nd run if verify
|
310
|
+
## fails.
|
311
|
+
#
|
312
|
+
## check for a valid signature in an extra run because gpg aborts if the
|
313
|
+
## signature cannot be verified (but it is still able to decrypt)
|
314
|
+
#sigoutput = run_gpg "#{payload_fn.path}"
|
315
|
+
#sig = self.old_verified_ok? sigoutput, $?
|
316
|
+
|
317
|
+
if armor
|
318
|
+
msg = RMail::Message.new
|
319
|
+
# Look for Charset, they are put before the base64 crypted part
|
320
|
+
charsets = payload.body.split("\n").grep(/^Charset:/)
|
321
|
+
if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/
|
322
|
+
output.transcode($encoding, $1)
|
323
|
+
end
|
324
|
+
msg.body = output
|
325
|
+
else
|
326
|
+
# It appears that some clients use Windows new lines - CRLF - but RMail
|
327
|
+
# splits the body and header on "\n\n". So to allow the parse below to
|
328
|
+
# succeed, we will convert the newlines to what RMail expects
|
329
|
+
output = output.gsub(/\r\n/, "\n")
|
330
|
+
# This is gross. This decrypted payload could very well be a multipart
|
331
|
+
# element itself, as opposed to a simple payload. For example, a
|
332
|
+
# multipart/signed element, like those generated by Mutt when encrypting
|
333
|
+
# and signing a message (instead of just clearsigning the body).
|
334
|
+
# Supposedly, decrypted_payload being a multipart element ought to work
|
335
|
+
# out nicely because Message::multipart_encrypted_to_chunks() runs the
|
336
|
+
# decrypted message through message_to_chunks() again to get any
|
337
|
+
# children. However, it does not work as intended because these inner
|
338
|
+
# payloads need not carry a MIME-Version header, yet they are fed to
|
339
|
+
# RMail as a top-level message, for which the MIME-Version header is
|
340
|
+
# required. This causes for the part not to be detected as multipart,
|
341
|
+
# hence being shown as an attachment. If we detect this is happening,
|
342
|
+
# we force the decrypted payload to be interpreted as MIME.
|
343
|
+
msg = RMail::Parser.read output
|
344
|
+
if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
|
345
|
+
output = "MIME-Version: 1.0\n" + output
|
346
|
+
output.fix_encoding!
|
347
|
+
msg = RMail::Parser.read output
|
348
|
+
end
|
349
|
+
end
|
350
|
+
notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
|
351
|
+
[notice, sig, msg]
|
352
|
+
end
|
353
|
+
|
354
|
+
private
|
355
|
+
|
356
|
+
def unknown_status lines=[]
|
357
|
+
Chunk::CryptoNotice.new :unknown, "Unable to determine validity of cryptographic signature", lines
|
358
|
+
end
|
359
|
+
|
360
|
+
def gpgme_exc_msg msg
|
361
|
+
err_msg = "Exception in GPGME call: #{msg}"
|
362
|
+
#info err_msg
|
363
|
+
err_msg
|
364
|
+
end
|
365
|
+
|
366
|
+
## here's where we munge rmail output into the format that signed/encrypted
|
367
|
+
## PGP/GPG messages should be
|
368
|
+
def format_payload payload
|
369
|
+
payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n")
|
370
|
+
end
|
371
|
+
|
372
|
+
# remove the hex key_id and info in ()
|
373
|
+
def simplify_sig_line sig_line, trusted
|
374
|
+
sig_line.sub!(/from [0-9A-F]{16} /, "from ")
|
375
|
+
if !trusted
|
376
|
+
sig_line.sub!(/Good signature/, "Good (untrusted) signature")
|
377
|
+
end
|
378
|
+
sig_line
|
379
|
+
end
|
380
|
+
|
381
|
+
def sig_output_lines signature
|
382
|
+
# It appears that the signature.to_s call can lead to a EOFError if
|
383
|
+
# the key is not found. So start by looking for the key.
|
384
|
+
ctx = GPGME::Ctx.new
|
385
|
+
begin
|
386
|
+
from_key = ctx.get_key(signature.fingerprint)
|
387
|
+
if GPGME::gpgme_err_code(signature.status) == GPGME::GPG_ERR_GENERAL
|
388
|
+
first_sig = "General error on signature verification for #{signature.fingerprint}"
|
389
|
+
elsif signature.to_s
|
390
|
+
first_sig = signature.to_s.sub(/from [0-9A-F]{16} /, 'from "') + '"'
|
391
|
+
else
|
392
|
+
first_sig = "Unknown error or empty signature"
|
393
|
+
end
|
394
|
+
rescue EOFError
|
395
|
+
from_key = nil
|
396
|
+
first_sig = "No public key available for #{signature.fingerprint}"
|
397
|
+
end
|
398
|
+
|
399
|
+
time_line = "Signature made " + signature.timestamp.strftime("%a %d %b %Y %H:%M:%S %Z") +
|
400
|
+
" using " + key_type(from_key, signature.fingerprint) +
|
401
|
+
"key ID " + signature.fingerprint[-8..-1]
|
402
|
+
output_lines = [time_line, first_sig]
|
403
|
+
|
404
|
+
trusted = false
|
405
|
+
if from_key
|
406
|
+
# first list all the uids
|
407
|
+
if from_key.uids.length > 1
|
408
|
+
aka_list = from_key.uids[1..-1]
|
409
|
+
aka_list.each { |aka| output_lines << ' aka "' + aka.uid + '"' }
|
410
|
+
end
|
411
|
+
|
412
|
+
# now we want to look at the trust of that key
|
413
|
+
if signature.validity != GPGME::GPGME_VALIDITY_FULL && signature.validity != GPGME::GPGME_VALIDITY_MARGINAL
|
414
|
+
output_lines << "WARNING: This key is not certified with a trusted signature!"
|
415
|
+
output_lines << "There is no indication that the signature belongs to the owner"
|
416
|
+
output_lines << "Full fingerprint is: " + (0..9).map {|i| signature.fpr[(i*4),4]}.join(":")
|
417
|
+
else
|
418
|
+
trusted = true
|
419
|
+
end
|
420
|
+
|
421
|
+
# finally, run the hook
|
422
|
+
output_lines << HookManager.run("sig-output",
|
423
|
+
{:signature => signature, :from_key => from_key})
|
424
|
+
end
|
425
|
+
return output_lines, trusted
|
426
|
+
end
|
427
|
+
|
428
|
+
def key_type key, fpr
|
429
|
+
return "" if key.nil?
|
430
|
+
subkey = key.subkeys.find {|subkey| subkey.fpr == fpr || subkey.keyid == fpr }
|
431
|
+
return "" if subkey.nil?
|
432
|
+
|
433
|
+
case subkey.pubkey_algo
|
434
|
+
when GPGME::PK_RSA then "RSA "
|
435
|
+
when GPGME::PK_DSA then "DSA "
|
436
|
+
when GPGME::PK_ELG then "ElGamel "
|
437
|
+
when GPGME::PK_ELG_E then "ElGamel "
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
441
|
+
# logic is:
|
442
|
+
# if gpgkey set for this account, then use that
|
443
|
+
# elsif only one account, then leave blank so gpg default will be user
|
444
|
+
# else set --local-user from_email_address
|
445
|
+
# NOTE: multiple signers doesn't seem to work with gpgme (2.0.2, 1.0.8)
|
446
|
+
#
|
447
|
+
def gen_sign_user_opts from
|
448
|
+
account = AccountManager.account_for from
|
449
|
+
account ||= AccountManager.default_account
|
450
|
+
if !account.gpgkey.nil?
|
451
|
+
opts = {:signer => account.gpgkey}
|
452
|
+
elsif AccountManager.user_emails.length == 1
|
453
|
+
# only one account
|
454
|
+
opts = {}
|
455
|
+
else
|
456
|
+
opts = {:signer => from}
|
457
|
+
end
|
458
|
+
opts
|
459
|
+
end
|
460
|
+
end
|
461
|
+
end
|