sup

A curses threads-with-tags style email client

sup.git

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

lib/sup/mbox.rb (5567B) - raw

      1 require 'uri'
      2 require 'set'
      3 require 'time'
      4 
      5 module Redwood
      6 
      7 class MBox < Source
      8   BREAK_RE = /^From \S+ (.+)$/
      9 
     10   include SerializeLabelsNicely
     11   yaml_properties :uri, :usual, :archived, :id, :labels
     12 
     13   attr_reader :labels
     14 
     15   ## uri_or_fp is horrific. need to refactor.
     16   def initialize uri_or_fp, usual=true, archived=false, id=nil, labels=nil
     17     @mutex = Mutex.new
     18     @labels = Set.new((labels || []) - LabelManager::RESERVED_LABELS)
     19 
     20     case uri_or_fp
     21     when String
     22       @expanded_uri = Source.expand_filesystem_uri(uri_or_fp)
     23       parts = /^([a-zA-Z0-9]*:(\/\/)?)(.*)/.match @expanded_uri
     24       if parts
     25         prefix = parts[1]
     26         @path = parts[3]
     27         uri = URI(prefix + Source.encode_path_for_uri(@path))
     28       else
     29         uri = URI(Source.encode_path_for_uri @expanded_uri)
     30         @path = uri.path
     31       end
     32 
     33       raise ArgumentError, "not an mbox uri" unless uri.scheme == "mbox"
     34       raise ArgumentError, "mbox URI ('#{uri}') cannot have a host: #{uri.host}" unless uri.host.nil? || uri.host.empty?
     35       raise ArgumentError, "mbox URI must have a path component" unless uri.path
     36       @f = nil
     37     else
     38       @f = uri_or_fp
     39       @path = uri_or_fp.path
     40       @expanded_uri = "mbox://#{Source.encode_path_for_uri @path}"
     41     end
     42 
     43     super uri_or_fp, usual, archived, id
     44   end
     45 
     46   def file_path; @path end
     47   def is_source_for? uri; super || (uri == @expanded_uri) end
     48 
     49   def self.suggest_labels_for path
     50     ## heuristic: use the filename as a label, unless the file
     51     ## has a path that probably represents an inbox.
     52     if File.dirname(path) =~ /\b(var|usr|spool)\b/
     53       []
     54     else
     55       [File.basename(path).downcase.intern]
     56     end
     57   end
     58 
     59   def ensure_open
     60     @f = File.open @path, 'rb' if @f.nil?
     61   end
     62   private :ensure_open
     63 
     64   def go_idle
     65     @mutex.synchronize do
     66       return if @f.nil? or @path.nil?
     67       @f.close
     68       @f = nil
     69     end
     70   end
     71 
     72   def load_header offset
     73     header = nil
     74     @mutex.synchronize do
     75       ensure_open
     76       @f.seek offset
     77       header = parse_raw_email_header @f
     78     end
     79     header
     80   end
     81 
     82   def load_message offset
     83     @mutex.synchronize do
     84       ensure_open
     85       @f.seek offset
     86       begin
     87         ## don't use RMail::Mailbox::MBoxReader because it doesn't properly ignore
     88         ## "From" at the start of a message body line.
     89         string = +""
     90         until @f.eof? || MBox::is_break_line?(l = @f.gets)
     91           string << l
     92         end
     93         RMail::Parser.read string
     94       rescue RMail::Parser::Error => e
     95         raise FatalSourceError, "error parsing mbox file: #{e.message}"
     96       end
     97     end
     98   end
     99 
    100   def raw_header offset
    101     ret = +""
    102     @mutex.synchronize do
    103       ensure_open
    104       @f.seek offset
    105       until @f.eof? || (l = @f.gets) =~ /^\r*$/
    106         ret << l
    107       end
    108     end
    109     ret
    110   end
    111 
    112   def raw_message offset
    113     enum_for(:each_raw_message_line, offset).reduce(:+)
    114   end
    115 
    116   def store_message date, from_email, &block
    117     need_blank = File.exist?(@path) && !File.zero?(@path)
    118     File.open(@path, "ab") do |f|
    119       f.puts if need_blank
    120       f.puts "From #{from_email} #{date.asctime}"
    121       yield f
    122     end
    123   end
    124 
    125   ## apparently it's a million times faster to call this directly if
    126   ## we're just moving messages around on disk, than reading things
    127   ## into memory with raw_message.
    128   ##
    129   def each_raw_message_line offset
    130     @mutex.synchronize do
    131       ensure_open
    132       @f.seek offset
    133       until @f.eof? || MBox::is_break_line?(l = @f.gets)
    134         yield l
    135       end
    136     end
    137   end
    138 
    139   def fallback_date_for_message offset
    140     ## This is a bit awkward... We treat the From line as a delimiter,
    141     ## not part of the message. So the offset is pointing *after* the
    142     ## From line for the desired message. With a bit of effort we can
    143     ## scan backwards to find its From line and extract a date from it.
    144     buf = @mutex.synchronize do
    145       ensure_open
    146       start = offset
    147       loop do
    148         start = (start - 200).clamp 0, 2**64
    149         @f.seek start
    150         buf = @f.read (offset - start)
    151         break buf if buf.include? ?\n or start == 0
    152       end
    153     end
    154     BREAK_RE.match buf.lines.last do |m|
    155       Time.strptime m[1], "%a %b %d %H:%M:%S %Y"
    156     end
    157   end
    158 
    159   def default_labels
    160     [:inbox, :unread]
    161   end
    162 
    163   def poll
    164     first_offset = first_new_message
    165     offset = first_offset
    166     end_offset = File.size @f
    167     while offset and offset < end_offset
    168       yield :add,
    169         :info => offset,
    170         :labels => (labels + default_labels),
    171         :progress => (offset - first_offset).to_f/end_offset
    172       offset = next_offset offset
    173     end
    174   end
    175 
    176   def next_offset offset
    177     @mutex.synchronize do
    178       ensure_open
    179       @f.seek offset
    180       nil while line = @f.gets and not MBox::is_break_line? line
    181       offset = @f.tell
    182       offset != File.size(@f) ? offset : nil
    183     end
    184   end
    185 
    186   ## TODO optimize this by iterating over allterms list backwards or
    187   ## storing source_info negated
    188   def last_indexed_message
    189     benchmark(:mbox_read_index) { Index.instance.enum_for(:each_source_info, self.id).map(&:to_i).max }
    190   end
    191 
    192   ## offset of first new message or nil
    193   def first_new_message
    194     next_offset(last_indexed_message || 0)
    195   end
    196 
    197   def self.is_break_line? l
    198     l =~ BREAK_RE or return false
    199     time = $1
    200     begin
    201       Time.strptime time, "%a %b %d %H:%M:%S %Y"
    202       true
    203     rescue NoMethodError, ArgumentError
    204       warn "found invalid date in potential mbox split line, not splitting: #{l.inspect}"
    205       false
    206     end
    207   end
    208 
    209   class Loader < self
    210     yaml_properties :uri, :usual, :archived, :id, :labels
    211   end
    212 end
    213 end