lib/sup.rb (14360B) - raw
1 # encoding: utf-8
2
3 require 'yaml'
4 require 'zlib'
5 require 'thread'
6 require 'fileutils'
7 require 'locale'
8 require 'ncursesw'
9 require 'rmail'
10 require 'uri'
11 begin
12 require 'fastthread'
13 rescue LoadError
14 end
15
16 class Object
17 ## this is for debugging purposes because i keep calling #id on the
18 ## wrong object and i want it to throw an exception
19 def id
20 raise "wrong id called on #{self.inspect}"
21 end
22 end
23
24 class Module
25 def yaml_properties *props
26 props = props.map { |p| p.to_s }
27
28 path = name.gsub(/::/, "/")
29 yaml_tag "tag:#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}/#{path}"
30
31 define_method :init_with do |coder|
32 initialize(*coder.map.values_at(*props))
33 end
34
35 define_method :encode_with do |coder|
36 coder.map = props.inject({}) do |hash, key|
37 hash[key] = instance_variable_get("@#{key}")
38 hash
39 end
40 end
41 end
42 end
43
44 module Redwood
45 BASE_DIR = ENV["SUP_BASE"] || File.join(ENV["HOME"], ".sup")
46 CONFIG_FN = File.join(BASE_DIR, "config.yaml")
47 COLOR_FN = File.join(BASE_DIR, "colors.yaml")
48 SOURCE_FN = File.join(BASE_DIR, "sources.yaml")
49 LABEL_FN = File.join(BASE_DIR, "labels.txt")
50 CONTACT_FN = File.join(BASE_DIR, "contacts.txt")
51 DRAFT_DIR = File.join(BASE_DIR, "drafts")
52 SENT_FN = File.join(BASE_DIR, "sent.mbox")
53 LOCK_FN = File.join(BASE_DIR, "lock")
54 SUICIDE_FN = File.join(BASE_DIR, "please-kill-yourself")
55 HOOK_DIR = File.join(BASE_DIR, "hooks")
56 SEARCH_FN = File.join(BASE_DIR, "searches.txt")
57 LOG_FN = File.join(BASE_DIR, "log")
58 SYNC_OK_FN = File.join(BASE_DIR, "sync-back-ok")
59
60 YAML_DOMAIN = "supmua.org"
61 LEGACY_YAML_DOMAIN = "masanjin.net"
62 YAML_DATE = "2006-10-01"
63 MAILDIR_SYNC_CHECK_SKIPPED = 'SKIPPED'
64 URI_ENCODE_CHARS = "!*'();:@&=+$,?#[] " # see https://en.wikipedia.org/wiki/Percent-encoding
65
66 ## record exceptions thrown in threads nicely
67 @exceptions = []
68 @exception_mutex = Mutex.new
69
70 attr_reader :exceptions
71 def record_exception e, name
72 @exception_mutex.synchronize do
73 @exceptions ||= []
74 @exceptions << [e, name]
75 end
76 end
77
78 def reporting_thread name
79 if $opts[:no_threads]
80 yield
81 else
82 ::Thread.new do
83 begin
84 yield
85 rescue Exception => e
86 record_exception e, name
87 end
88 end
89 end
90 end
91
92 module_function :reporting_thread, :record_exception, :exceptions
93
94 ## one-stop shop for yamliciousness
95 def save_yaml_obj o, fn, safe=false, backup=false
96 o = if o.is_a?(Array)
97 o.map { |x| (x.respond_to?(:before_marshal) && x.before_marshal) || x }
98 elsif o.respond_to? :before_marshal
99 o.before_marshal
100 else
101 o
102 end
103
104 mode = if File.exist? fn
105 File.stat(fn).mode
106 else
107 0600
108 end
109
110 if backup
111 backup_fn = fn + '.bak'
112 if File.exist?(fn) && File.size(fn) > 0
113 File.open(backup_fn, "w", mode) do |f|
114 File.open(fn, "r") { |old_f| FileUtils.copy_stream old_f, f }
115 f.fsync
116 end
117 end
118 File.open(fn, "w") do |f|
119 f.puts o.to_yaml
120 f.fsync
121 end
122 elsif safe
123 safe_fn = "#{File.dirname fn}/safe_#{File.basename fn}"
124 File.open(safe_fn, "w", mode) do |f|
125 f.puts o.to_yaml
126 f.fsync
127 end
128 FileUtils.mv safe_fn, fn
129 else
130 File.open(fn, "w", mode) do |f|
131 f.puts o.to_yaml
132 f.fsync
133 end
134 end
135 end
136
137 def load_yaml_obj fn, compress=false
138 o = if File.exist? fn
139 raw_contents = if compress
140 Zlib::GzipReader.open(fn) { |f| f.read }
141 else
142 File::open(fn) { |f| f.read }
143 end
144 ## fix up malformed tag URIs created by earlier versions of sup
145 raw_contents.gsub!(/!supmua.org,2006-10-01\/(\S+)/) { |m| "!<tag:supmua.org,2006-10-01/#{$1}>" }
146 if YAML.respond_to?(:unsafe_load) # Ruby 3.1+
147 YAML::unsafe_load raw_contents
148 else
149 YAML::load raw_contents
150 end
151 end
152 if o.is_a?(Array)
153 o.each { |x| x.after_unmarshal! if x.respond_to?(:after_unmarshal!) }
154 else
155 o.after_unmarshal! if o.respond_to?(:after_unmarshal!)
156 end
157 o
158 end
159
160 def managers
161 %w(HookManager SentManager ContactManager LabelManager AccountManager
162 DraftManager UpdateManager PollManager CryptoManager UndoManager
163 SourceManager SearchManager IdleManager).map { |x| Redwood.const_get x.to_sym }
164 end
165
166 def start bypass_sync_check = false
167 managers.each { |x| fail "#{x} already instantiated" if x.instantiated? }
168
169 FileUtils.mkdir_p Redwood::BASE_DIR
170 $config = load_config Redwood::CONFIG_FN
171 @log_io = File.open(Redwood::LOG_FN, 'a')
172 Redwood::Logger.add_sink @log_io
173 Redwood::HookManager.init Redwood::HOOK_DIR
174 Redwood::SentManager.init $config[:sent_source] || 'sup://sent'
175 Redwood::ContactManager.init Redwood::CONTACT_FN
176 Redwood::LabelManager.init Redwood::LABEL_FN
177 Redwood::AccountManager.init $config[:accounts]
178 Redwood::DraftManager.init Redwood::DRAFT_DIR
179 Redwood::SearchManager.init Redwood::SEARCH_FN
180
181 managers.each { |x| x.init unless x.instantiated? }
182
183 return if bypass_sync_check
184
185 if $config[:sync_back_to_maildir]
186 if not File.exist? Redwood::SYNC_OK_FN
187 Redwood.warn_syncback <<EOS
188 It appears that the "sync_back_to_maildir" option has been changed
189 from false to true since the last execution of sup.
190 EOS
191 $stderr.puts <<EOS
192
193 Should I complain about this again? (Y/n)
194 EOS
195 File.open(Redwood::SYNC_OK_FN, 'w') {|f| f.write(Redwood::MAILDIR_SYNC_CHECK_SKIPPED) } if STDIN.gets.chomp.downcase == 'n'
196 end
197 elsif not $config[:sync_back_to_maildir] and File.exist? Redwood::SYNC_OK_FN
198 File.delete(Redwood::SYNC_OK_FN)
199 end
200 end
201
202 def check_syncback_settings
203 # don't check if syncback was never performed
204 return unless File.exist? Redwood::SYNC_OK_FN
205 active_sync_sources = File.readlines(Redwood::SYNC_OK_FN).collect { |e| e.strip }.find_all { |e| not e.empty? }
206 return if active_sync_sources.length == 1 and active_sync_sources[0] == Redwood::MAILDIR_SYNC_CHECK_SKIPPED
207 sources = SourceManager.sources
208 newly_synced = sources.select { |s| s.is_a? Maildir and s.sync_back_enabled? and not active_sync_sources.include? s.uri }
209 unless newly_synced.empty?
210
211 details =<<EOS
212 It appears that the option "sync_back" of the following source(s)
213 has been changed from false to true since the last execution of
214 sup:
215
216 EOS
217 newly_synced.each do |s|
218 details += "#{s} (usual: #{s.usual})\n"
219 end
220
221 Redwood.warn_syncback details
222 end
223 end
224
225 def self.warn_syncback details
226 $stderr.puts <<EOS
227 WARNING
228 -------
229
230 #{details}
231
232 It is *strongly* recommended that you run "sup-sync-back-maildir"
233 before continuing, otherwise you might lose changes you have made in sup
234 to your Xapian index.
235
236 This script should be run each time you change the
237 "sync_back_to_maildir" flag in config.yaml from false to true or
238 the "sync_back" flag is changed to true for a source in sources.yaml.
239
240 Please run "sup-sync-back-maildir -h" for more information and why this
241 is needed.
242
243 Note that if you have any sources that are not marked as 'ususal' in
244 sources.yaml you need to manually specify them when running the
245 sup-sync-back-maildir script.
246
247 Are you really sure you want to continue? (y/N)
248 EOS
249 abort "Aborted" unless STDIN.gets.chomp.downcase == 'y'
250 end
251
252 def finish
253 Redwood::LabelManager.save if Redwood::LabelManager.instantiated?
254 Redwood::ContactManager.save if Redwood::ContactManager.instantiated?
255 Redwood::SearchManager.save if Redwood::SearchManager.instantiated?
256 Redwood::Logger.remove_sink @log_io
257
258 managers.each { |x| x.deinstantiate! if x.instantiated? }
259
260 @log_io.close if @log_io
261 @log_io = nil
262 $config = nil
263 end
264
265 ## not really a good place for this, so I'll just dump it here.
266 ##
267 ## a source error is either a FatalSourceError or an OutOfSyncSourceError.
268 ## the superclass SourceError is just a generic.
269 def report_broken_sources opts={}
270 return unless BufferManager.instantiated?
271
272 broken_sources = SourceManager.sources.select { |s| s.error.is_a? FatalSourceError }
273 unless broken_sources.empty?
274 BufferManager.spawn_unless_exists("Broken source notification for #{broken_sources.join(',')}", opts) do
275 TextMode.new(<<EOM)
276 Source error notification
277 -------------------------
278
279 Hi there. It looks like one or more message sources is reporting
280 errors. Until this is corrected, messages from these sources cannot
281 be viewed, and new messages will not be detected.
282
283 #{broken_sources.map { |s| "Source: " + s.to_s + "\n Error: " + s.error.message.wrap(70).join("\n ")}.join("\n\n")}
284 EOM
285 #' stupid ruby-mode
286 end
287 end
288
289 desynced_sources = SourceManager.sources.select { |s| s.error.is_a? OutOfSyncSourceError }
290 unless desynced_sources.empty?
291 BufferManager.spawn_unless_exists("Out-of-sync source notification for #{broken_sources.join(',')}", opts) do
292 TextMode.new(<<EOM)
293 Out-of-sync source notification
294 -------------------------------
295
296 Hi there. It looks like one or more sources has fallen out of sync
297 with my index. This can happen when you modify these sources with
298 other email clients. (Sorry, I don't play well with others.)
299
300 Until this is corrected, messages from these sources cannot be viewed,
301 and new messages will not be detected. Luckily, this is easy to correct!
302
303 #{desynced_sources.map do |s|
304 "Source: " + s.to_s +
305 "\n Error: " + s.error.message.wrap(70).join("\n ") +
306 "\n Fix: sup-sync --changed #{s.to_s}"
307 end}
308 EOM
309 #' stupid ruby-mode
310 end
311 end
312 end
313
314
315 ## set up default configuration file
316 def load_config filename
317 default_config = {
318 :editor => ENV["EDITOR"] || "/usr/bin/vim -f -c 'setlocal spell spelllang=en_us' -c 'set filetype=mail'",
319 :thread_by_subject => false,
320 :edit_signature => false,
321 :ask_for_from => false,
322 :ask_for_to => true,
323 :ask_for_cc => true,
324 :ask_for_bcc => false,
325 :ask_for_subject => true,
326 :account_selector => true,
327 :confirm_no_attachments => true,
328 :confirm_top_posting => true,
329 :jump_to_open_message => true,
330 :discard_snippets_from_encrypted_messages => false,
331 :load_more_threads_when_scrolling => true,
332 :default_attachment_save_dir => "",
333 :sent_source => "sup://sent",
334 :archive_sent => true,
335 :poll_interval => 300,
336 :wrap_width => 0,
337 :slip_rows => 0,
338 :indent_spaces => 2,
339 :col_jump => 2,
340 :stem_language => "english",
341 :sync_back_to_maildir => false,
342 :continuous_scroll => false,
343 :always_edit_async => false,
344 }
345 if File.exist? filename
346 config = Redwood::load_yaml_obj filename
347 abort "#{filename} is not a valid configuration file (it's a #{config.class}, not a hash)" unless config.is_a?(Hash)
348 default_config.merge config
349 else
350 require 'etc'
351 require 'socket'
352 name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first.force_encoding($encoding).fix_encoding! rescue nil
353 name ||= ENV["USER"]
354 email = ENV["USER"] + "@" +
355 begin
356 Addrinfo.getaddrinfo(Socket.gethostname, 'smtp').first.getnameinfo.first
357 rescue SocketError
358 Socket.gethostname
359 end
360
361 config = {
362 :accounts => {
363 :default => {
364 :name => name.dup.fix_encoding!,
365 :email => email.dup.fix_encoding!,
366 :alternates => [],
367 :sendmail => "/usr/sbin/sendmail -oem -ti",
368 :signature => File.join(ENV["HOME"], ".signature"),
369 :gpgkey => ""
370 }
371 },
372 }
373 config.merge! default_config
374 begin
375 Redwood::save_yaml_obj config, filename, false, true
376 rescue StandardError => e
377 $stderr.puts "warning: #{e.message}"
378 end
379 config
380 end
381 end
382
383 module_function :save_yaml_obj, :load_yaml_obj, :start, :finish,
384 :report_broken_sources, :load_config, :managers,
385 :check_syncback_settings
386 end
387
388 require 'sup/version'
389 require "sup/util"
390 require "sup/hook"
391 require "sup/time"
392
393 ## everything we need to get logging working
394 require "sup/logger/singleton"
395
396 ## determine encoding and character set
397 $encoding = Locale.current.charset
398 $encoding = "UTF-8" if $encoding == "utf8"
399 $encoding = "UTF-8" if $encoding == "UTF8"
400 if $encoding
401 debug "using character set encoding #{$encoding.inspect}"
402 else
403 warn "can't find character set by using locale, defaulting to utf-8"
404 $encoding = "UTF-8"
405 end
406
407 # test encoding
408 teststr = "test"
409 teststr.encode('UTF-8')
410 begin
411 teststr.encode($encoding)
412 rescue Encoding::ConverterNotFoundError
413 warn "locale encoding is invalid, defaulting to utf-8"
414 $encoding = "UTF-8"
415 end
416
417 require "sup/buffer"
418 require "sup/keymap"
419 require "sup/mode"
420 require "sup/modes/scroll_mode"
421 require "sup/modes/text_mode"
422 require "sup/modes/log_mode"
423 require "sup/update"
424 require "sup/message_chunks"
425 require "sup/message"
426 require "sup/source"
427 require "sup/mbox"
428 require "sup/maildir"
429 require "sup/person"
430 require "sup/account"
431 require "sup/thread"
432 require "sup/interactive_lock"
433 require "sup/index"
434 require "sup/textfield"
435 require "sup/colormap"
436 require "sup/label"
437 require "sup/contact"
438 require "sup/tagger"
439 require "sup/draft"
440 require "sup/poll"
441 require "sup/crypto"
442 require "sup/undo"
443 require "sup/horizontal_selector"
444 require "sup/modes/line_cursor_mode"
445 require "sup/modes/help_mode"
446 require "sup/modes/edit_message_mode"
447 require "sup/modes/edit_message_async_mode"
448 require "sup/modes/compose_mode"
449 require "sup/modes/resume_mode"
450 require "sup/modes/forward_mode"
451 require "sup/modes/reply_mode"
452 require "sup/modes/label_list_mode"
453 require "sup/modes/contact_list_mode"
454 require "sup/modes/thread_view_mode"
455 require "sup/modes/thread_index_mode"
456 require "sup/modes/label_search_results_mode"
457 require "sup/modes/search_results_mode"
458 require "sup/modes/person_search_results_mode"
459 require "sup/modes/inbox_mode"
460 require "sup/modes/buffer_list_mode"
461 require "sup/modes/poll_mode"
462 require "sup/modes/file_browser_mode"
463 require "sup/modes/completion_mode"
464 require "sup/modes/console_mode"
465 require "sup/sent"
466 require "sup/search"
467 require "sup/modes/search_list_mode"
468 require "sup/idle"
469
470 $:.each do |base|
471 d = File.join base, "sup/share/modes/"
472 Redwood::Mode.load_all_modes d if File.directory? d
473 end