require 'thin' require 'mauve/mauve_thread' require 'digest/sha1' module Mauve # # The POP3 server, where messages can also be read. # class Pop3Server < MauveThread include Singleton attr_reader :port, :ip # Initialize the server # # Default port is 1110 # Default IP is 0.0.0.0 # def initialize super self.port = 1110 self.ip = "0.0.0.0" end # # Set the port # # @param [Integer] pr # @raise [ArgumentError] if the port is not sane # ~ def port=(pr) raise ArgumentError, "port must be an integer between 0 and #{2**16-1}" unless pr.is_a?(Integer) and pr < 2**16 and pr > 0 @port = pr end # # Set the IP address. Unfortunately IPv6 is not OK. # # @param [String] i The IP address required. # def ip=(i) raise ArgumentError, "ip must be a string" unless i.is_a?(String) # # Use ipaddr to sanitize our IP. # IPAddr.new(i) @ip = i end # @return [Log4r::Logger] def logger @logger ||= Log4r::Logger.new(self.class.to_s) end # # This stops the server # def stop if @server.running? @server.stop else @server.stop! end super end # # This stops the server faster than stop # def join @server.stop! if @server super end private # # This tarts the server, and keeps it going. # def main_loop unless @server and @server.running? @server = Mauve::Pop3Backend.new(@ip.to_s, @port) logger.info "Listening on #{@server.to_s}" # # The next statment doesn't return. # @server.start end end end # # This is the Pop3 Server itself. It is based on the Thin HTTP server, and hence EventMachine. # class Pop3Backend < Thin::Backends::TcpServer # # @return [Log4r::Logger] def logger @logger ||= Log4r::Logger.new(self.class.to_s) end # Initialize a new connection to the server def connect @signature = EventMachine.start_server(@host, @port, Pop3Connection) end # Disconnect the server, but only if EventMachine is still going. def disconnect # # Only do this if EventMachine is still going.. The http_server may have # stopped it already. # EventMachine.stop_server(@signature) if EventMachine.reactor_running? end end # # This class represents and individual connection, and understands some POP3 # commands. # class Pop3Connection < EventMachine::Connection # The username attr_reader :user # Default CR+LF combo. CRLF = "\r\n" # @return [Log4r::Logger] def logger @logger ||= Log4r::Logger.new(self.class.to_s) end # This is called once the connection has been established. It says hello # to the client, and resets the state. def post_init logger.info "New connection" send_data "+OK #{self.class.to_s} started" @state = :authorization @user = nil @messages = [] @level = nil end # This returns a list of commands allowed in a state. # # @param [Symbol] The state to query, defaults to the current state. # @return [Array] An array of permitted comands. # def permitted_commands(state=@state) case @state when :authorization %w(QUIT USER PASS CAPA) when :transaction %w(QUIT STAT LIST RETR DELE NOOP RSET UIDL CAPA) when :update %w(QUIT) end end # This returns a list of capabilities in a given state. # # @param [Symbol] The state to query, defaults to the current state. # @return [Array] An array of capabilities. def capabilities(state=@state) case @state when :transaction %w(CAPA UIDL) when :authorization %w(CAPA UIDL USER) else [] end end # This method handles a command, and parses it. # # The following POP3 commands are understood: # QUIT # USER # PASS # STAT # LIST # RETR # DELE # NOOP # RSET # CAPA # UIDL # # The command is checked against a list of permitted commands, given the # state of the connection, and returns an error if the command is # forbidden. # # @param [String] data The data to process. # def receive_data (data) data.split(CRLF).each do |cmd| break if error? if cmd =~ Regexp.new('\A('+self.permitted_commands.join("|")+')\b') case $1 when "QUIT" do_process_quit cmd when "USER" do_process_user cmd when "PASS" do_process_pass cmd when "STAT" do_process_stat cmd when "LIST" do_process_list cmd when "RETR" do_process_retr cmd when "DELE" do_process_dele cmd when "NOOP" do_process_noop cmd when "RSET" do_process_rset cmd when "CAPA" do_process_capa cmd when "UIDL" do_process_uidl cmd else do_process_error cmd end else do_process_error cmd end end end # This sends the data back to the user. A CR+LF is joined to the end of # the data. # # @param [String] d The data to send back. def send_data(d) d += CRLF super unless error? end private # This deals with CAPA, returning a string of capabilities in the current # connection state. # # @param [String] a The complete CAPA command sent by the client. # def do_process_capa(a) send_data (["+OK Capabilities follow:"] + self.capabilities + ["."]).join(CRLF) end # This deals with the USER command. # # Any of low, normal, urgent can be appended to the username, to select # only alarms of that level to be shown. # # e.g. # patrick+low # # will show only alerts of a LOW level. # # @param [String] s The complete USER command sent by the client. # def do_process_user(s) allowed_levels = Mauve::AlertGroup::LEVELS.collect{|l| l.to_s} if s =~ /\AUSER +(\w+)\+(#{allowed_levels.join("|")})/ # Allow alerts to be shown by level. # @user = $1 @level = $2 # send_data "+OK Only going to show #{@level} alerts." elsif s =~ /\AUSER +([\w]+)/ @user = $1 send_data "+OK" else send_data "-ERR Username not understood." end end # This processes the PASS command. It uses the Mauve::Authenticate class # to authenticate the user. Once authenticated, the state is set to :transaction. # # @param [String] s The complete PASS command sent by the client. def do_process_pass(s) if @user and s =~ /\APASS +(\S+)/ if Mauve::Authentication.authenticate(@user, $1) @state = :transaction send_data "+OK Welcome #{@user} (#{@level})." else send_data "-ERR Authentication failed." end else send_data "-ERR USER comes first." end end # # This just sends an "ERR Unknown command" string back to the user. # # @param [String] a The complete command from the client that caused this error. def do_process_error(a) send_data "-ERR Unknown comand." end # This does a NOOP. # # @param [String] a The complete NOOP command from the client. def do_process_noop(a) send_data "+OK Thanks." end # Delete is processed as a NOOP alias do_process_dele do_process_noop # This logs a user out, and closes the connection. The state is set to :update. # # @param [String] a The complete QUIT command from the client. def do_process_quit(a) @state = :update send_data "+OK bye." close_connection_after_writing end # This sends the number of messages, and their size back to the client. # # @param [String] a The complete STAT command from the client. def do_process_stat(a) send_data "+OK #{self.messages.length} #{self.messages.inject(0){|s,m| s+= m[1].length}}" end # This sends a list of the messages back to the client. # # @param [String] a The complete LIST command from the client. # def do_process_list(a) d = [] if a =~ /\ALIST +(\d+)\b/ ind = $1.to_i if ind > 0 and ind <= self.messages.length d << "+OK #{ind} #{self.messages[ind-1][1].length}" else d << "-ERR Unknown message." end else d << "+OK #{self.messages.length} messages (#{self.messages.inject(0){|s,m| s+= m[1].length}} octets)." self.messages.each_with_index{|m,i| d << "#{i+1} #{m[1].length}"} d << "." end send_data d.join(CRLF) end # This sends the UID of a message back to the client. # # @param [String] a The complete UIDL command from the client. def do_process_uidl(a) if a =~ /\AUIDL +(\d+)\b/ ind = $1.to_i if ind > 0 and ind <= self.messages.length m = self.messages[ind-1][0].id send_data "+OK #{ind} #{m}" else send_data "-ERR Message not found." end else d = ["+OK "] self.messages.each_with_index{|m,i| d << "#{i+1} #{m[0].id}"} d << "." send_data d.join(CRLF) end end # This retrieves a message for the client. # # @param [String] a The complete RETR command from the client. # def do_process_retr(a) if a =~ /\ARETR +(\d+)\b/ ind = $1.to_i if ind > 0 and ind <= self.messages.length alert_changed, msg = self.messages[ind-1] send_data ["+OK #{msg.length} octets", msg, "."].join(CRLF) note = "#{alert_changed.update_type.capitalize} notification downloaded via POP3 by #{@user}" logger.info note+" about #{alert_changed}." h = History.new(:alerts => [alert_changed.alert_id], :type => "notification", :event => note, :user => @user) logger.error "Unable to save history due to #{h.errors.inspect}" if !h.save else send_data "-ERR Message not found." end else send_data "-ERR Boo." end end protected # # These are the messages in the mailbox. It looks for the first 100 alert_changed, and formats them into emails, and returns an array of # # [alert_changed, email] # # @return [Array] Array of alert_changeds and emails. # def messages if @messages.empty? @messages = [] email = Configuration.current.notification_methods['email'] alerts_seen = [] # # A maximum of the 100 most recent alerts. # AlertChanged.first(100, :person => self.user, :was_relevant => true).each do |a| # # Not interested in alerts # next unless @level.nil? or a.level.to_s == @level # # Only interested in alerts # next unless a.alert.is_a?(Mauve::Alert) # # Only one message per alert. # next if alerts_seen.include?([a.alert_id, a.update_type]) relevant = case a.update_type when "raised" a.alert.raised? when "acknowledged" a.alert.acknowledged? when "cleared" a.alert.cleared? else false end next unless relevant alerts_seen << [a.alert_id, a.update_type] @messages << [a, email.prepare_message(self.user+"@"+Server.instance.hostname, a.alert, [])] end end @messages end end end