sup

A curses threads-with-tags style email client

sup.git

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

lib/sup/maildir.rb (8056B) - raw

      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     parts = /^([a-zA-Z0-9]*:(\/\/)?)(.*)/.match @expanded_uri
     16     if parts
     17       prefix = parts[1]
     18       @path = parts[3]
     19       uri = URI(prefix + Source.encode_path_for_uri(@path))
     20     else
     21       uri = URI(Source.encode_path_for_uri @path)
     22       @path = uri.path
     23     end
     24 
     25     raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir"
     26     raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" unless uri.host.nil? || uri.host.empty?
     27     raise ArgumentError, "maildir URI must have a path component" unless uri.path
     28 
     29     @sync_back = sync_back
     30     # sync by default if not specified
     31     @sync_back = true if @sync_back.nil?
     32 
     33     @dir = URI.decode_www_form_component uri.path
     34     @labels = Set.new(labels || [])
     35     @mutex = Mutex.new
     36     @ctimes = { 'cur' => Time.at(0), 'new' => Time.at(0) }
     37   end
     38 
     39   def file_path; @dir end
     40   def self.suggest_labels_for path; [] end
     41   def is_source_for? uri; super || (uri == @expanded_uri); end
     42 
     43   def supported_labels?
     44     [:draft, :starred, :forwarded, :replied, :unread, :deleted]
     45   end
     46 
     47   def sync_back_enabled?
     48     @sync_back
     49   end
     50 
     51   def store_message date, from_email, &block
     52     stored = false
     53     new_fn = new_maildir_basefn + ':2,S'
     54     Dir.chdir(@dir) do |d|
     55       tmp_path = File.join(@dir, 'tmp', new_fn)
     56       new_path = File.join(@dir, 'new', new_fn)
     57       begin
     58         sleep 2 if File.stat(tmp_path)
     59 
     60         File.stat(tmp_path)
     61       rescue Errno::ENOENT #this is what we want.
     62         begin
     63           File.open(tmp_path, 'wb') do |f|
     64             yield f #provide a writable interface for the caller
     65             f.fsync
     66           end
     67 
     68           File.safe_link tmp_path, new_path
     69           stored = true
     70         ensure
     71           File.unlink tmp_path if File.exist? tmp_path
     72         end
     73       end #rescue Errno...
     74     end #Dir.chdir
     75 
     76     stored
     77   end
     78 
     79   def each_raw_message_line id
     80     with_file_for(id) do |f|
     81       until f.eof?
     82         yield f.gets
     83       end
     84     end
     85   end
     86 
     87   def load_header id
     88     with_file_for(id) { |f| parse_raw_email_header f }
     89   end
     90 
     91   def load_message id
     92     with_file_for(id) { |f| RMail::Parser.read f }
     93   end
     94 
     95   def sync_back id, labels
     96     synchronize do
     97       debug "syncing back maildir message #{id} with flags #{labels.to_a}"
     98       flags = maildir_reconcile_flags id, labels
     99       maildir_mark_file id, flags
    100     end
    101   end
    102 
    103   def raw_header id
    104     ret = ""
    105     with_file_for(id) do |f|
    106       until f.eof? || (l = f.gets) =~ /^$/
    107         ret += l
    108       end
    109     end
    110     ret
    111   end
    112 
    113   def raw_message id
    114     with_file_for(id) { |f| f.read }
    115   end
    116 
    117   def fallback_date_for_message id
    118     File.mtime File.join(@dir, id)
    119   end
    120 
    121   ## XXX use less memory
    122   def poll
    123     added = []
    124     deleted = []
    125     updated = []
    126     @ctimes.each do |d,prev_ctime|
    127       subdir = File.join @dir, d
    128       debug "polling maildir #{subdir}"
    129       raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir
    130       ctime = File.ctime subdir
    131       next if prev_ctime >= ctime
    132       @ctimes[d] = ctime
    133 
    134       old_ids = benchmark(:maildir_read_index) { Index.instance.enum_for(:each_source_info, self.id, "#{d}/").to_a }
    135       new_ids = benchmark(:maildir_read_dir) {
    136         Dir.open(subdir).select {
    137           |f| !File.directory? f}.map {
    138             |x| File.join(d,File.basename(x)) }.sort }
    139       added += new_ids - old_ids
    140       deleted += old_ids - new_ids
    141       debug "#{old_ids.size} in index, #{new_ids.size} in filesystem"
    142     end
    143 
    144     ## find updated mails by checking if an id is in both added and
    145     ## deleted arrays, meaning that its flags changed or that it has
    146     ## been moved, these ids need to be removed from added and deleted
    147     add_to_delete = del_to_delete = []
    148     map = Hash.new { |hash, key| hash[key] = [] }
    149     deleted.each do |id_del|
    150         map[maildir_data(id_del)[0]].push id_del
    151     end
    152     added.each do |id_add|
    153         map[maildir_data(id_add)[0]].each do |id_del|
    154           updated.push [ id_del, id_add ]
    155           add_to_delete.push id_add
    156           del_to_delete.push id_del
    157         end
    158     end
    159     added -= add_to_delete
    160     deleted -= del_to_delete
    161     debug "#{added.size} added, #{deleted.size} deleted, #{updated.size} updated"
    162     total_size = added.size+deleted.size+updated.size
    163 
    164     added.each_with_index do |id,i|
    165       yield :add,
    166       :info => id,
    167       :labels => @labels + maildir_labels(id) + [:inbox],
    168       :progress => i.to_f/total_size
    169     end
    170 
    171     deleted.each_with_index do |id,i|
    172       yield :delete,
    173       :info => id,
    174       :progress => (i.to_f+added.size)/total_size
    175     end
    176 
    177     updated.each_with_index do |id,i|
    178       yield :update,
    179       :old_info => id[0],
    180       :new_info => id[1],
    181       :labels => @labels + maildir_labels(id[1]),
    182       :progress => (i.to_f+added.size+deleted.size)/total_size
    183     end
    184     nil
    185   end
    186 
    187   def labels? id
    188     maildir_labels id
    189   end
    190 
    191   def maildir_labels id
    192     flags = maildir_flags id
    193     labels = []
    194     labels << :unread unless flags.member? :seen
    195     labels << :deleted if flags.member? :trashed
    196     labels << :starred if flags.member? :flagged
    197     labels << :forwarded if flags.member? :passed
    198     labels << :replied if flags.member? :replied
    199     labels << :draft if flags.member? :draft
    200     labels
    201   end
    202 
    203   def maildir_flags id
    204     maildir_data(id)[2].each_char.map do |c|
    205       case c
    206       when 'D' then :draft
    207       when 'F' then :flagged
    208       when 'P' then :passed
    209       when 'R' then :replied
    210       when 'S' then :seen
    211       when 'T' then :trashed
    212       end
    213     end
    214   end
    215 
    216   def valid? id
    217     File.exist? File.join(@dir, id)
    218   end
    219 
    220 private
    221 
    222   def new_maildir_basefn
    223     Kernel::srand()
    224     "#{Time.now.to_i.to_s}.#{$$}#{Kernel.rand(1000000)}.#{MYHOSTNAME}"
    225   end
    226 
    227   def with_file_for id
    228     fn = File.join(@dir, id)
    229     begin
    230       File.open(fn, 'rb') { |f| yield f }
    231     rescue SystemCallError, IOError => e
    232       raise FatalSourceError, "Problem reading file for id #{id.inspect}: #{fn.inspect}: #{e.message}."
    233     end
    234   end
    235 
    236   def maildir_data id
    237     id = File.basename id
    238     # Flags we recognize are DFPRST
    239     id =~ %r{^([^:]+):([12]),([A-Za-z]*)$}
    240     [($1 || id), ($2 || "2"), ($3 || "")]
    241   end
    242 
    243   def maildir_reconcile_flags id, labels
    244       new_flags = Set.new( maildir_data(id)[2].each_char )
    245 
    246       # Set flags based on labels for the six flags we recognize
    247       if labels.member? :draft then new_flags.add?( "D" ) else new_flags.delete?( "D" ) end
    248       if labels.member? :starred then new_flags.add?( "F" ) else new_flags.delete?( "F" ) end
    249       if labels.member? :forwarded then new_flags.add?( "P" ) else new_flags.delete?( "P" ) end
    250       if labels.member? :replied then new_flags.add?( "R" ) else new_flags.delete?( "R" ) end
    251       if not labels.member? :unread then new_flags.add?( "S" ) else new_flags.delete?( "S" ) end
    252       if labels.member? :deleted or labels.member? :killed then new_flags.add?( "T" ) else new_flags.delete?( "T" ) end
    253 
    254       ## Flags must be stored in ASCII order according to Maildir
    255       ## documentation
    256       new_flags.to_a.sort.join
    257   end
    258 
    259   def maildir_mark_file orig_path, flags
    260     @mutex.synchronize do
    261       new_base = (flags.include?("S")) ? "cur" : "new"
    262       md_base, md_ver, md_flags = maildir_data orig_path
    263 
    264       return if md_flags == flags
    265 
    266       new_loc = File.join new_base, "#{md_base}:#{md_ver},#{flags}"
    267       orig_path = File.join @dir, orig_path
    268       new_path  = File.join @dir, new_loc
    269       tmp_path  = File.join @dir, "tmp", "#{md_base}:#{md_ver},#{flags}"
    270 
    271       File.safe_link orig_path, tmp_path
    272       File.unlink orig_path
    273       File.safe_link tmp_path, new_path
    274       File.unlink tmp_path
    275 
    276       new_loc
    277     end
    278   end
    279 end
    280 
    281 end