Fixed a typo in the version of the base-files dependency.
[stand.git] / stand
blob01aaff27e08f0b9cd685020dede0ecd994597586
1 #!/usr/bin/ruby
3 # Copyright Mark Longair (c) 2002, 2005, 2008 Reuben Thomas (c) 2005-2007.
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18 # This is a simple script for fetching mail from POP3 accounts.
19 # On a Debian system, you'll need the following packages:
21 # ruby
22 # libruby
23 # libopenssl-ruby
24 # libsqlite3-ruby
26 # See http://mythic-beasts.com/~mark/software/stand/
27 # for further remarks.
29 $program_name = 'stand'
31 require 'getoptlong'
32 require 'yaml'
33 require 'socket'
34 require 'net/pop'
35 require 'openssl'
36 require 'timeout'
37 require 'sqlite3'
39 # FIXME: I don't understand the recent changes that have been made to
40 # InternetMessageIO and broke the old version of these two classes, so
41 # while this seems to work at the moment, I expect it to break at any
42 # time.
44 module Net
46 class SSLIO < InternetMessageIO
48 def SSLIO.old_open(addr, port, open_timeout = 30, read_timeout = 30, debug_output = nil)
50 tcp_socket = timeout(open_timeout) { TCPsocket.new(addr, port) }
52 @ssl_context = OpenSSL::SSL::SSLContext.new()
54 if $ssl_verify
55 @ssl_context.ca_path = '/etc/ssl/certs'
56 @ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER
57 else
58 @ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
59 end
61 socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, @ssl_context)
63 socket.connect
65 io = new(socket)
66 io.read_timeout = read_timeout
67 io.debug_output = debug_output
70 end
72 end
74 end
76 # ----------------------------------------------------------------------
78 # POP3 over SSL class
80 module Net
82 class POP3s < POP3
84 def self.port
85 '995'
86 end
88 def POP3s.socket_type
89 SSLIO
90 end
92 end
94 end
96 # ----------------------------------------------------------------------
98 # Command line argument parsing...
100 # Some default values...
102 $configuration_file = "#{ENV['HOME']}/.stand.yaml"
103 $configuration_file_pretty = "~/.stand.yaml"
104 $uidl_database_filename = "#{ENV['HOME']}/.stand-uidls"
106 def usage
108 print <<EOF
109 Usage: #{$program_name} [OPTION]...
111 -h, --help Display this message and exit.
112 -k, --keep Don\'t delete messages on server.
113 -n, --no-ssl-verify Don\'t verify the peer\'s certificate when using SSL.
114 -f <FILENAME>, --configuration-file=<FILENAME>
115 Specify the configuration file to use.
116 (default: #{$configuration_file_pretty})
117 -r <SECONDS>, --reconnect-after=<SECONDS>
118 Reconnect to the server if <SECONDS> seconds has
119 elapsed since the last connection.
124 options = GetoptLong.new(
125 [ "--help", "-h", GetoptLong::NO_ARGUMENT ],
126 [ "--keep", "-k", GetoptLong::NO_ARGUMENT ],
127 [ "--no-ssl-verify", "-n", GetoptLong::NO_ARGUMENT ],
128 [ "--configuration-file", "-f", GetoptLong::REQUIRED_ARGUMENT ],
129 [ "--reconnect-after", "-r", GetoptLong::REQUIRED_ARGUMENT ]
132 $keep = false
133 $ssl_verify = true
134 $reconnect_after = nil
136 begin
138 options.each do |opt, arg|
140 case opt
141 when "--help"
142 usage
143 exit
144 when "--keep"
145 $keep = true
146 when "--no-ssl-verify"
147 $ssl_verify = false
148 when "--configuration-file"
149 $configuration_file = arg
150 when "--reconnect-after"
151 $reconnect_after = Float(arg)
156 rescue
158 print "Bad command line option: " + $! + "\n"
159 usage
160 exit
164 if $keep and $reconnect_after
165 puts "There should be no reason to specify --reconnect-after with --keep"
166 exit
169 # ----------------------------------------------------------------------
171 # A fairly idiotic progress class.
173 class Progress
175 def initialize(maximum)
176 @maximum = maximum
177 @current_value = 0
180 def increase_by(i)
181 set @current_value + i
184 def set(v)
185 block_change = (v / 1024) - (@current_value / 1024)
186 if block_change >= 0
187 (1..block_change).each { print "." }
188 $stdout.flush
189 else
190 backspaces = - block_change
191 (1..backspaces).each { print "\b" }
192 $stdout.flush
194 @current_value = v
199 # ----------------------------------------------------------------------
201 # This class deals with connecting to a POP3 server, and fetching the
202 # mail from it.
204 class POP3Account
206 attr_accessor :host, :user, :port
208 attr_reader :command
210 def initialize(host, user, password, use_ssl, use_apop, port, command)
211 @host = host
212 @user = user
213 @password = password
214 @use_ssl = use_ssl
215 @use_apop = use_apop
216 @port = port
217 @command = command
220 def to_s
221 "#{@user} at #{@host}:#{@port}#{ @use_ssl ? ' (SSL)' : ''}"
224 def account_uidl(uidl)
225 "#{@user}@#{@host}:#{@port}##{uidl}"
228 # These three methods manipulate the UIDL database.
230 def add_uidl_to_db(db,u)
231 db.execute( "INSERT INTO uidls VALUES (?)", account_uidl(u) )
234 def delete_uidls_from_db(db,a)
235 a.each do |u|
236 db.execute( "DELETE FROM uidls WHERE uidl = ?", account_uidl(u) )
240 def db_has_uidl?(db,u)
241 rows = db.execute( "SELECT uidl FROM uidls WHERE uidl = ?", account_uidl(u) )
242 rows.length > 0
245 # Fetch all the new messages from this POP3 account, deleting
246 # successfully downloaded messages.
248 def fetch_all
250 # Choose correct POP class
252 if @use_ssl
253 pop3_class = Net::POP3s
254 else
255 pop3_class = Net::POP3
258 # Process all the messages
260 i = 0
261 total = 0
263 while true
265 delete_list = []
267 pop3_class.start(@host, @port, @user, @password, @use_apop) do |pop|
269 connected_at = Time.now
271 if i == 0 and not pop.mails.empty?
272 total = pop.n_mails
273 plural = total != 1
274 puts " There " + (plural ? "are" : "is") + " #{total} message" +
275 (plural ? "s" : "") + " available."
278 if i >= total
279 if i == 0
280 puts " No mail."
282 return
285 pop.each_mail do |m|
287 i += 1
289 print " Retrieving message #{i} of #{total} [#{m.length} bytes]: "
291 uidl = m.uidl
292 $db.transaction
293 if db_has_uidl? $db, uidl
294 if $keep
295 puts " [Skipping]"
296 else
297 puts " [Skipping and deleting]"
298 m.delete
299 delete_list.push uidl
301 next
304 progress = Progress.new(m.size)
306 # Fetch the message...
307 message = ""
308 m.pop do |chunk|
309 chunk.gsub!(/\r\n/, "\n")
310 message << chunk
311 progress.increase_by chunk.length
314 # Deliver the message...
315 Kernel.open("|-", "w+") do |f|
316 if f
317 f.write message
318 else
319 begin
320 exec @command
321 rescue
322 raise "Couldn't exec \"#{@command}\": #{$!}\n"
327 if $?.success?
328 unless add_uidl_to_db $db, uidl
329 $db.rollback
330 raise "Adding the UIDL to the database failed."
332 else
333 $db.rollback
334 raise "The command \"#{@command}\" failed with exit code #{$?.exitstatus}"
336 $db.commit
338 # We've successfully dealt with the message now...
339 unless $keep
340 print " [Deleting]"
341 m.delete
342 delete_list.push uidl
344 puts
346 if $reconnect_after and Time.now - connected_at >= $reconnect_after
347 break
353 $db.transaction do |db|
354 delete_uidls_from_db db, delete_list
362 # ----------------------------------------------------------------------
364 begin
366 # Make sure that the UIDL database file is present.
368 $db = SQLite3::Database.new($uidl_database_filename)
370 $db.execute( "CREATE TABLE IF NOT EXISTS uidls ( uidl TEXT PRIMARY KEY )" )
372 # Read in the configuration file...
374 accounts = open($configuration_file, "r") { |f| YAML.load(f) }
375 raise "No accounts specified in configuration file." if accounts.length == 0
377 # This is the main loop. Go through every account, fetching all the
378 # new mail...
380 accounts.each_index do |i|
382 a = accounts[i]
384 # Do some quick checks on what we've just parsed...
386 valid_account_properties = ['Host', 'User', 'Pass', 'Command', 'SSL', 'APOP', 'Port']
388 (a.keys - valid_account_properties).each do |property|
389 puts "Warning: in account #{i + 1}, the unknown account property `#{property}' was ignored."
392 ['Host', 'User', 'Pass'].each do |required|
393 raise "Missing `#{required}:' line in account #{i + 1}" unless a[required]
394 unless a[required].class == String
395 raise "The value in the `#{required}:' property of account #{i + 1} " +
396 "(`#{a[required].to_s}' was not interpreted as a string; you " +
397 "may need to quote it"
401 ['SSL', 'APOP'].each do |boolean|
402 if a['boolean']
403 unless [ TrueClass, FalseClass ].include? a[boolean].class
404 raise "In account #{i + 1}, `#{boolean}' property must be `yes', `no', `true' or `false'"
409 if a['Port']
410 unless a['Port'].class == Fixnum
411 raise "In account #{i + 1} the Port property must be an integer (not `#{a['Port']}\')"
413 else
414 a['Port'] = a['SSL'] ? 995 : 110
417 unless a['Command']
418 a['Command'] = 'procmail -f-'
421 account = POP3Account.new(a['Host'], a['User'], a['Pass'], a['SSL'],
422 a['APOP'], a['Port'], a['Command'])
424 begin
425 puts "Checking account: #{account}"
426 puts " Piping mail to the command: #{account.command}"
427 account.fetch_all
428 rescue Interrupt
429 puts "Interrupt received: exiting."
430 break
431 rescue
432 puts " Error fetching mail from account `#{account}': " + $!
437 rescue
438 puts "Fatal error: " + $!