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