sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/

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