]> git.cworth.org Git - sup/blob - lib/sup/maildir.rb
maildir cpu usage regression fix
[sup] / lib / sup / maildir.rb
1 require 'rmail'
2 require 'uri'
3
4 module Redwood
5
6 ## Maildir doesn't provide an ordered unique id, which is what Sup
7 ## requires to be really useful. So we must maintain, in memory, a
8 ## mapping between Sup "ids" (timestamps, essentially) and the
9 ## pathnames on disk.
10
11 class Maildir < Source
12   SCAN_INTERVAL = 30 # seconds
13
14   ## remind me never to use inheritance again.
15   yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels, :mtimes
16   def initialize uri, last_date=nil, usual=true, archived=false, id=nil, labels=[], mtimes={}
17     super uri, last_date, usual, archived, id
18     uri = URI(Source.expand_filesystem_uri(uri))
19
20     raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir"
21     raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" if uri.host
22     raise ArgumentError, "maildir URI must have a path component" unless uri.path
23
24     @dir = uri.path
25     @labels = (labels || []).freeze
26     @ids = []
27     @ids_to_fns = {}
28     @last_scan = nil
29     @mutex = Mutex.new
30     #the mtime from the subdirs in the maildir with the unix epoch as default.
31     #these are used to determine whether scanning the directory for new mail
32     #is a worthwhile effort
33     @mtimes = { 'cur' => Time.at(0), 'new' => Time.at(0) }.merge(mtimes || {})
34     @dir_ids = { 'cur' => [], 'new' => [] }
35   end
36
37   def file_path; @dir end
38   def self.suggest_labels_for path; [] end
39   def is_source_for? uri; super || (URI(Source.expand_filesystem_uri(uri)) == URI(self.uri)); end
40
41   def check
42     scan_mailbox
43     return unless start_offset
44
45     start = @ids.index(cur_offset || start_offset) or raise OutOfSyncSourceError, "Unknown message id #{cur_offset || start_offset}." # couldn't find the most recent email
46   end
47   
48   def each_raw_message_line id
49     scan_mailbox
50     with_file_for(id) do |f|
51       until f.eof?
52         yield f.gets
53       end
54     end
55   end
56
57   def load_header id
58     scan_mailbox
59     with_file_for(id) { |f| MBox::read_header f }
60   end
61
62   def load_message id
63     scan_mailbox
64     with_file_for(id) { |f| RMail::Parser.read f }
65   end
66
67   def raw_header id
68     scan_mailbox
69     ret = ""
70     with_file_for(id) do |f|
71       until f.eof? || (l = f.gets) =~ /^$/
72         ret += l
73       end
74     end
75     ret
76   end
77
78   def raw_message id
79     scan_mailbox
80     with_file_for(id) { |f| f.read }
81   end
82
83   def scan_mailbox opts={}
84     return unless @ids.empty? || opts[:rescan]
85     return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
86
87     initial_poll = @ids.empty?
88
89     Redwood::log "scanning maildir #@dir..."
90     begin
91       @mtimes.each_key do |d|
92         subdir = File.join(@dir, d)
93         raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir
94
95         mtime = File.mtime subdir
96
97         #only scan the dir if the mtime is more recent (or we haven't polled
98         #since startup)
99         if @mtimes[d] < mtime || initial_poll
100           @mtimes[d] = mtime
101           @dir_ids[d] = []
102           Dir[File.join(subdir, '*')].map do |fn|
103             id = make_id fn
104             @dir_ids[d] << id
105             @ids_to_fns[id] = fn
106           end
107         else
108           Redwood::log "no poll on #{d}.  mtime on indicates no new messages."
109         end
110       end
111       @ids = @dir_ids.values.flatten.uniq.sort!
112     rescue SystemCallError, IOError => e
113       raise FatalSourceError, "Problem scanning Maildir directories: #{e.message}."
114     end
115     
116     Redwood::log "done scanning maildir"
117     @last_scan = Time.now
118   end
119   synchronized :scan_mailbox
120
121   def each
122     scan_mailbox
123     return unless start_offset
124
125     start = @ids.index(cur_offset || start_offset) or raise OutOfSyncSourceError, "Unknown message id #{cur_offset || start_offset}." # couldn't find the most recent email
126
127     start.upto(@ids.length - 1) do |i|         
128       id = @ids[i]
129       self.cur_offset = id
130       yield id, @labels + (seen?(id) ? [] : [:unread]) + (trashed?(id) ? [:deleted] : []) + (flagged?(id) ? [:starred] : [])
131     end
132   end
133
134   def start_offset
135     scan_mailbox
136     @ids.first
137   end
138
139   def end_offset
140     scan_mailbox :rescan => true
141     @ids.last + 1
142   end
143
144   def pct_done; 100.0 * (@ids.index(cur_offset) || 0).to_f / (@ids.length - 1).to_f; end
145
146   def draft? msg; maildir_data(msg)[2].include? "D"; end
147   def flagged? msg; maildir_data(msg)[2].include? "F"; end
148   def passed? msg; maildir_data(msg)[2].include? "P"; end
149   def replied? msg; maildir_data(msg)[2].include? "R"; end
150   def seen? msg; maildir_data(msg)[2].include? "S"; end
151   def trashed? msg; maildir_data(msg)[2].include? "T"; end
152
153   def mark_draft msg; maildir_mark_file msg, "D" unless draft? msg; end
154   def mark_flagged msg; maildir_mark_file msg, "F" unless flagged? msg; end
155   def mark_passed msg; maildir_mark_file msg, "P" unless passed? msg; end
156   def mark_replied msg; maildir_mark_file msg, "R" unless replied? msg; end
157   def mark_seen msg; maildir_mark_file msg, "S" unless seen? msg; end
158   def mark_trashed msg; maildir_mark_file msg, "T" unless trashed? msg; end
159
160 private
161
162   def make_id fn
163     #doing this means 1 syscall instead of 2 (File.mtime, File.size).
164     #makes a noticeable difference on nfs.
165     stat = File.stat(fn)
166     # use 7 digits for the size. why 7? seems nice.
167     sprintf("%d%07d", stat.mtime, stat.size % 10000000).to_i
168   end
169
170   def with_file_for id
171     fn = @ids_to_fns[id] or raise OutOfSyncSourceError, "No such id: #{id.inspect}."
172     begin
173       File.open(fn) { |f| yield f }
174     rescue SystemCallError, IOError => e
175       raise FatalSourceError, "Problem reading file for id #{id.inspect}: #{fn.inspect}: #{e.message}."
176     end
177   end
178
179   def maildir_data msg
180     fn = File.basename @ids_to_fns[msg]
181     fn =~ %r{^([^:,]+):([12]),([DFPRST]*)$}
182     [($1 || fn), ($2 || "2"), ($3 || "")]
183   end
184
185   ## not thread-safe on msg
186   def maildir_mark_file msg, flag
187     orig_path = @ids_to_fns[msg]
188     orig_base, orig_fn = File.split(orig_path)
189     new_base = orig_base.slice(0..-4) + 'cur'
190     tmp_base = orig_base.slice(0..-4) + 'tmp'
191     md_base, md_ver, md_flags = maildir_data msg
192     md_flags += flag; md_flags = md_flags.split(//).sort.join.squeeze
193     new_path = File.join new_base, "#{md_base}:#{md_ver},#{md_flags}"
194     tmp_path = File.join tmp_base, "#{md_base}:#{md_ver},#{md_flags}"
195     File.link orig_path, tmp_path
196     File.unlink orig_path
197     File.link tmp_path, new_path
198     File.unlink tmp_path
199     @ids_to_fns[msg] = new_path
200   end
201 end
202
203 end