Remove the -w argument to ruby and wrapped all the UIDL database manipulation for...
[stand.git] / stand
blobe5697240e1650c83c993b86869e840516bb29c60
1 #!/usr/bin/ruby
3 # Copyright Mark Longair (c) 2002, 2005, Reuben Thomas (c) 2005, 2006.
5 # This is a simple script for fetching mail from POP3 accounts.
6 # On a Debian system, you'll need the following packages:
8 # ruby
9 # libruby
10 # libopenssl-ruby
11 # libsqlite3-ruby
13 # See http://mythic-beasts.com/~mark/software/stand/
14 # for further remarks.
17 $program_name = 'stand'
19 require 'getoptlong'
20 require 'yaml'
21 require 'socket'
22 require 'net/pop'
23 require 'openssl'
24 require 'timeout'
25 require 'sqlite3'
27 # FIXME: I don't understand the recent changes that have been made to
28 # InternetMessageIO and broke the old version of these two classes, so
29 # while this seems to work at the moment, I expect it to break at any
30 # time.
32 module Net
34 class SSLIO < InternetMessageIO
36 def SSLIO.old_open(addr, port, open_timeout = 30, read_timeout = 30, debug_output = nil)
38 tcp_socket = timeout(open_timeout) { TCPsocket.new(addr, port) }
40 @ssl_context = OpenSSL::SSL::SSLContext.new()
42 if $ssl_verify
43 @ssl_context.ca_path = '/etc/ssl/certs'
44 @ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
45 else
46 @ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
47 end
49 socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, @ssl_context)
51 socket.connect
53 io = new(socket)
54 io.read_timeout = read_timeout
55 io.debug_output = debug_output
58 end
60 end
62 end
64 # ----------------------------------------------------------------------
66 # POP3 over SSL class
68 module Net
70 class POP3s < POP3
72 def self.port
73 '995'
74 end
76 def POP3s.socket_type
77 SSLIO
78 end
80 end
82 end
84 # ----------------------------------------------------------------------
86 # Command line argument parsing...
88 # Some default values...
90 $configuration_file = "#{ENV['HOME']}/.stand.yaml"
91 $configuration_file_pretty = "~/.stand.yaml"
92 $uidl_database_filename = "#{ENV['HOME']}/.stand-uidls"
94 def usage
96 print <<EOF
97 Usage: #{$program_name} [OPTION]...
99 -h, --help Display this message and exit.
100 -k, --keep Don't delete messages on server.
101 -n, --no-ssl-verify Don't verify the peer's certificate when using SSL.
102 -f <FILENAME>, --configuration-file=<FILENAME>
103 Specify the configuration file to use.
104 (default: #{$configuration_file_pretty})
109 options = GetoptLong.new(
110 [ "--help", "-h", GetoptLong::NO_ARGUMENT ],
111 [ "--keep", "-k", GetoptLong::NO_ARGUMENT ],
112 [ "--no-ssl-verify", "-n", GetoptLong::NO_ARGUMENT ],
113 [ "--configuration-file", "-f", GetoptLong::REQUIRED_ARGUMENT ]
116 $keep = false
117 $ssl_verify = true
118 $break_every = nil
120 begin
122 options.each do |opt, arg|
124 case opt
125 when "--help"
126 usage
127 exit
128 when "--keep"
129 $keep = true
130 when "--no-ssl-verify"
131 $ssl_verify = false
132 when "--configuration-file"
133 $configuration_file = arg
138 rescue
140 print "Bad command line option: " + $! + "\n"
141 usage
142 exit
146 # ----------------------------------------------------------------------
148 # A fairly idiotic progress class.
150 class Progress
152 def initialize(maximum)
153 @maximum = maximum
154 @current_value = 0
157 def increase_by(i)
158 set @current_value + i
161 def set(v)
162 block_change = (v / 1024) - (@current_value / 1024)
163 if block_change >= 0
164 (1..block_change).each { print "." }
165 $stdout.flush
166 else
167 backspaces = - block_change
168 (1..backspaces).each { print "\b" }
169 $stdout.flush
171 @current_value = v
176 # ----------------------------------------------------------------------
178 # This class deals with connecting to a POP3 server, and fetching the
179 # mail from it.
181 class POP3Account
183 attr_accessor :host, :user, :port
185 attr_reader :command
187 def initialize(host, user, password, use_ssl, use_apop, port, command)
188 @host = host
189 @user = user
190 @password = password
191 @use_ssl = use_ssl
192 @use_apop = use_apop
193 @port = port
194 @command = command
197 def to_s
198 "#{@user} at #{@host}:#{@port}#{ @use_ssl ? ' (SSL)' : ''}"
201 def account_uidl(uidl)
202 "#{@user}@#{@host}:#{@port}##{uidl}"
205 # These three methods manipulate the UIDL database.
207 def add_uidl_to_db(db,u)
208 db.execute( "INSERT INTO uidls VALUES (?)", account_uidl(u) )
211 def delete_uidls_from_db(db,a)
212 a.each do |u|
213 db.execute( "DELETE FROM uidls WHERE uidl = ?", account_uidl(u) )
217 def db_has_uidl?(db,u)
218 rows = db.execute( "SELECT uidl FROM uidls WHERE uidl = ?", account_uidl(u) )
219 rows.length > 0
222 # Fetch all the new messages from this POP3 account, deleting
223 # successfully downloaded messages.
225 def fetch_all
227 # Choose correct POP class
229 if @use_ssl
230 pop3_class = Net::POP3s
231 else
232 pop3_class = Net::POP3
235 # Process all the messages
237 i = 0
238 total = 0
239 delete_list = []
241 $db.transaction do |db|
243 pop3_class.start(@host, @port, @user, @password, @use_apop) do |pop|
245 if pop.mails.empty?
246 puts " No mail."
247 return
248 else
249 total = pop.n_mails
250 plural = total != 1
251 puts " There " + (plural ? "are" : "is") + " #{total} message" +
252 (plural ? "s" : "") + " available."
255 pop.each_mail do |m|
257 i += 1
259 print " Retrieving message #{i} of #{total} [#{m.length} bytes]: "
261 uidl = m.uidl
262 if db_has_uidl? db, uidl
263 if $keep
264 puts " [Skipping]"
265 else
266 puts " [Skipping and deleting]"
267 m.delete
268 delete_list.push uidl
270 next
273 progress = Progress.new(m.size)
275 # Fetch the message...
276 message = ""
277 m.pop do |chunk|
278 chunk.gsub!(/\r\n/, "\n")
279 message << chunk
280 progress.increase_by chunk.length
283 # Deliver the message...
284 Kernel.open("|-", "w+") do |f|
285 if f
286 f.write message
287 else
288 begin
289 exec @command
290 rescue
291 raise "Couldn't exec \"#{@command}\": #{$!}\n"
296 if $?.success?
297 add_uidl_to_db db, uidl
298 else
299 raise "The command \"#{@command}\" failed with exit code #{$?.exitstatus}"
302 # We've successfully dealt with the message now...
303 unless $keep
304 print " [Deleting]"
305 m.delete
306 delete_list.push uidl
308 puts
313 delete_uidls_from_db db, delete_list
320 # ----------------------------------------------------------------------
323 # ----------------------------------------------------------------------
325 begin
327 # Make sure that the UIDL database file is present.
329 $db = SQLite3::Database.new($uidl_database_filename)
331 $db.transaction do |d|
332 d.execute( "CREATE TABLE IF NOT EXISTS uidls ( uidl TEXT PRIMARY KEY )" )
335 # Read in the configuration file...
337 accounts = open($configuration_file, "r") { |f| YAML.load(f) }
338 raise "No accounts specified in configuration file." if accounts.length == 0
340 # This is the main loop. Go through every account, fetching all the
341 # new mail...
343 accounts.each_index do |i|
345 a = accounts[i]
347 # Do some quick checks on what we've just parsed...
349 valid_account_properties = ['Host', 'User', 'Pass', 'Command', 'SSL', 'APOP', 'Port']
351 (a.keys - valid_account_properties).each do |property|
352 puts "Warning: in account #{i + 1}, the unknown account property `#{property}' was ignored."
355 ['Host', 'User', 'Pass'].each do |required|
356 raise "Missing `#{required}:' line in account #{i + 1}" unless a[required]
357 unless a[required].class == String
358 raise "The value in the `#{required}:' property of account #{i + 1} " +
359 "(`#{a[required].to_s}' was not interpreted as a string; you " +
360 "may need to quote it"
364 ['SSL', 'APOP'].each do |boolean|
365 if a['boolean']
366 unless [ TrueClass, FalseClass ].include? a[boolean].class
367 raise "In account #{i + 1}, `#{boolean}' property must be `yes', `no', `true' or `false'"
372 if a['Port']
373 unless a['Port'].class == Fixnum
374 raise "In account #{i + 1} the Port property must be an integer (not `#{a['Port']}\')"
376 else
377 a['Port'] = a['SSL'] ? 995 : 110
380 unless a['Command']
381 a['Command'] = 'procmail -f-'
384 account = POP3Account.new(a['Host'], a['User'], a['Pass'], a['SSL'],
385 a['APOP'], a['Port'], a['Command'])
387 begin
388 puts "Checking account: #{account}"
389 puts " Piping mail to the command: #{account.command}"
390 account.fetch_all
391 rescue Interrupt => i_exception
392 puts "Interrupt Exception from fetch_all, backtrace:\n" + i_exception.backtrace.join("\n")
393 rescue
394 puts " Error fetching mail from account `#{account}': " + $!
399 rescue Interrupt
400 puts "Interrupt received: exiting."
402 rescue
403 puts "Fatal error: " + $!