sup

A curses threads-with-tags style email client

sup.git

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

lib/sup/source.rb (7492B) - raw

      1 require "sup/rfc2047"
      2 require "monitor"
      3 
      4 module Redwood
      5 
      6 class SourceError < StandardError
      7   def initialize *a
      8     raise "don't instantiate me!" if SourceError.is_a?(self.class)
      9     super
     10   end
     11 end
     12 class OutOfSyncSourceError < SourceError; end
     13 class FatalSourceError < SourceError; end
     14 
     15 class Source
     16   ## Implementing a new source should be easy, because Sup only needs
     17   ## to be able to:
     18   ##  1. See how many messages it contains
     19   ##  2. Get an arbitrary message
     20   ##  3. (optional) see whether the source has marked it read or not
     21   ##
     22   ## In particular, Sup doesn't need to move messages, mark them as
     23   ## read, delete them, or anything else. (Well, it's nice to be able
     24   ## to delete them, but that is optional.)
     25   ##
     26   ## Messages are identified internally based on the message id, and stored
     27   ## with an unique document id. Along with the message, source information
     28   ## that can contain arbitrary fields (set up by the source) is stored. This
     29   ## information will be passed back to the source when a message in the
     30   ## index (Sup database) needs to be identified to its source, e.g. when
     31   ## re-reading or modifying a unique message.
     32   ##
     33   ## To write a new source, subclass this class, and implement:
     34   ##
     35   ## - initialize
     36   ## - load_header offset
     37   ## - load_message offset
     38   ## - raw_header offset
     39   ## - raw_message offset
     40   ## - store_message (optional)
     41   ## - poll (loads new messages)
     42   ## - go_idle (optional)
     43   ##
     44   ## All exceptions relating to accessing the source must be caught
     45   ## and rethrown as FatalSourceErrors or OutOfSyncSourceErrors.
     46   ## OutOfSyncSourceErrors should be used for problems that a call to
     47   ## sup-sync will fix (namely someone's been playing with the source
     48   ## from another client); FatalSourceErrors can be used for anything
     49   ## else (e.g. the imap server is down or the maildir is missing.)
     50   ##
     51   ## Finally, be sure the source is thread-safe, since it WILL be
     52   ## pummelled from multiple threads at once.
     53   ##
     54   ## Examples for you to look at: mbox.rb and maildir.rb.
     55 
     56   bool_accessor :usual, :archived
     57   attr_reader :uri, :usual
     58   attr_accessor :id
     59 
     60   def initialize uri, usual=true, archived=false, id=nil
     61     raise ArgumentError, "id must be an integer: #{id.inspect}" unless id.is_a? Integer if id
     62 
     63     @uri = uri
     64     @usual = usual
     65     @archived = archived
     66     @id = id
     67 
     68     @poll_lock = Monitor.new
     69   end
     70 
     71   ## overwrite me if you have a disk incarnation
     72   def file_path; nil end
     73 
     74   def to_s; @uri.to_s; end
     75   def == o; o.uri == uri; end
     76   def is_source_for? uri; uri == @uri; end
     77 
     78   def read?; false; end
     79 
     80   ## release resources that are easy to reacquire. it is called
     81   ## after processing a source (e.g. polling) to prevent resource
     82   ## leaks (esp. file descriptors).
     83   def go_idle; end
     84 
     85   ## Returns an array containing all the labels that are natively
     86   ## supported by this source
     87   def supported_labels?; [] end
     88 
     89   ## Returns an array containing all the labels that are currently in
     90   ## the location filename
     91   def labels? info; [] end
     92 
     93   def fallback_date_for_message info; end
     94 
     95   ## Yields values of the form [Symbol, Hash]
     96   ## add: info, labels, progress
     97   ## delete: info, progress
     98   def poll
     99     unimplemented
    100   end
    101 
    102   def valid? info
    103     true
    104   end
    105 
    106   def synchronize &block
    107     @poll_lock.synchronize(&block)
    108   end
    109 
    110   def try_lock
    111     acquired = @poll_lock.try_enter
    112     if acquired
    113       debug "lock acquired for: #{self}"
    114     else
    115       debug "could not acquire lock for: #{self}"
    116     end
    117     acquired
    118   end
    119 
    120   def unlock
    121     @poll_lock.exit
    122     debug "lock released for: #{self}"
    123   end
    124 
    125   ## utility method to read a raw email header from an IO stream and turn it
    126   ## into a hash of key-value pairs. minor special semantics for certain headers.
    127   ##
    128   ## THIS IS A SPEED-CRITICAL SECTION. Everything you do here will have a
    129   ## significant effect on Sup's processing speed of email from ALL sources.
    130   ## Little things like string interpolation, regexp interpolation, += vs <<,
    131   ## all have DRAMATIC effects. BE CAREFUL WHAT YOU DO!
    132   def self.parse_raw_email_header f
    133     header = {}
    134     last = nil
    135 
    136     while(line = f.gets)
    137       case line
    138       ## these three can occur multiple times, and we want the first one
    139       when /^(Delivered-To|X-Original-To|Envelope-To):\s*(.*?)\s*$/i; header[last = $1.downcase] ||= $2
    140       ## regular header: overwrite (not that we should see more than one)
    141       ## TODO: figure out whether just using the first occurrence changes
    142       ## anything (which would simplify the logic slightly)
    143       when /^([^:\s]+):\s*(.*?)\s*$/i; header[last = $1.downcase] = $2
    144       when /^\r*$/; break # blank line signifies end of header
    145       else
    146         if last
    147           header[last] << " " unless header[last].empty?
    148           header[last] << line.strip
    149         end
    150       end
    151     end
    152 
    153     %w(subject from to cc bcc).each do |k|
    154       v = header[k] or next
    155       next unless Rfc2047.is_encoded? v
    156       header[k] = begin
    157         Rfc2047.decode_to $encoding, v
    158       rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence
    159         #debug "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}"
    160         v
    161       end
    162     end
    163     header
    164   end
    165 
    166 protected
    167 
    168   ## convenience function
    169   def parse_raw_email_header f; self.class.parse_raw_email_header f end
    170 
    171   def Source.expand_filesystem_uri uri
    172     uri.gsub "~", File.expand_path("~")
    173   end
    174 
    175   def Source.encode_path_for_uri path
    176     path.gsub(Regexp.new("[#{Regexp.quote(URI_ENCODE_CHARS)}]")) { |c|
    177       c.each_byte.map { |x| sprintf("%%%02X", x) }.join
    178     }
    179   end
    180 end
    181 
    182 ## if you have a @labels instance variable, include this
    183 ## to serialize them nicely as an array, rather than as a
    184 ## nasty set.
    185 module SerializeLabelsNicely
    186   def before_marshal # can return an object
    187     c = clone
    188     c.instance_eval { @labels = (@labels.to_a.map { |l| l.to_s }).sort }
    189     c
    190   end
    191 
    192   def after_unmarshal!
    193     @labels = Set.new(@labels.to_a.map { |s| s.to_sym })
    194   end
    195 end
    196 
    197 class SourceManager
    198   include Redwood::Singleton
    199 
    200   def initialize
    201     @sources = {}
    202     @sources_dirty = false
    203     @source_mutex = Monitor.new
    204   end
    205 
    206   def [](id)
    207     @source_mutex.synchronize { @sources[id] }
    208   end
    209 
    210   def add_source source
    211     @source_mutex.synchronize do
    212       raise "duplicate source!" if @sources.include? source
    213       @sources_dirty = true
    214       max = @sources.max_of { |id, s| s.is_a?(DraftLoader) || s.is_a?(SentLoader) ? 0 : id }
    215       source.id ||= (max || 0) + 1
    216       ##source.id += 1 while @sources.member? source.id
    217       @sources[source.id] = source
    218     end
    219   end
    220 
    221   def sources
    222     ## favour the inbox by listing non-archived sources first
    223     @source_mutex.synchronize { @sources.values }.sort_by { |s| s.id }.partition { |s| !s.archived? }.flatten
    224   end
    225 
    226   def source_for uri
    227     expanded_uri = Source.expand_filesystem_uri(uri)
    228     sources.find { |s| s.is_source_for? expanded_uri }
    229   end
    230 
    231   def usual_sources; sources.find_all { |s| s.usual? }; end
    232   def unusual_sources; sources.find_all { |s| !s.usual? }; end
    233 
    234   def load_sources fn=Redwood::SOURCE_FN
    235     source_array = Redwood::load_yaml_obj(fn) || []
    236     @source_mutex.synchronize do
    237       @sources = Hash[*(source_array).map { |s| [s.id, s] }.flatten]
    238       @sources_dirty = false
    239     end
    240   end
    241 
    242   def save_sources fn=Redwood::SOURCE_FN, force=false
    243     @source_mutex.synchronize do
    244       if @sources_dirty || force
    245         Redwood::save_yaml_obj sources, fn, false, true
    246       end
    247       @sources_dirty = false
    248     end
    249   end
    250 end
    251 
    252 end