diff --git a/README.md b/README.md index a2fe709..125ec46 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Usage -v, --verbose Output more information -l, --logfile FILE Write log to FILE. Outputs to STDOUT (or /var/log/post_office.log when daemonized) by default. + -m, --mailbox NUM Use separate mailboxes -s, --smtp PORT Specify SMTP port to use -p, --pop3 PORT Specify POP3 port to use @@ -69,6 +70,13 @@ PostOffice daemon can be started on Mac OS X system startup. The Startup Item is stored in */Library/StartupItems/PostOffice* +Distinct Mailboxes +------------------ + +By default, sent emails are broadcast to all clients connected to the POP3 server. To simulate delivery to specific email addresses, use the `--mailbox NUM` setting. `NUM` specifies the number of POP3 ports to be opened. The first port number is specified with `--pop3 PORT`, and increments from there. + +When connecting to the POP3 server with the mailbox setting enabled, set the `username` field to the desired email address. + Planned features ---------------- diff --git a/bin/post_office b/bin/post_office index 96ec5cf..6149134 100755 --- a/bin/post_office +++ b/bin/post_office @@ -7,6 +7,7 @@ require 'thread' require 'smtp_server.rb' require 'pop_server.rb' require 'config_file.rb' +require 'store.rb' options = ConfigFile.detect.read @@ -17,22 +18,27 @@ optparse = OptionParser.new do |opts| opts.banner = "Usage: #{__FILE__} [options]" options[:verbose] ||= false - opts.on( '-v', '--verbose', 'Output more information' ) do + opts.on('-v', '--verbose', 'Output more information') do options[:verbose] = true end options[:logfile] ||= nil - opts.on( '-l', '--logfile FILE', 'Write log to FILE. Outputs to STDOUT (or /var/log/post_office.log when daemonized) by default.' ) do |file| + opts.on('-l', '--logfile FILE', 'Write log to FILE. Outputs to STDOUT (or /var/log/post_office.log when daemonized) by default.') do |file| options[:logfile] = file end + options[:mailbox] ||= 1 + opts.on('-m', '--mailbox NUM', 'Use separate mailboxes') do |mailbox| + options[:mailbox] = mailbox + end + options[:smtp_port] ||= 25 - opts.on( '-s', '--smtp PORT', 'Specify SMTP port to use' ) do |port| + opts.on('-s', '--smtp PORT', 'Specify SMTP port to use') do |port| options[:smtp_port] = port end options[:pop3_port] ||= 110 - opts.on( '-p', '--pop3 PORT', 'Specify POP3 port to use' ) do |port| + opts.on('-p', '--pop3 PORT', 'Specify POP3 port to use') do |port| options[:pop3_port] = port end @@ -45,7 +51,7 @@ optparse = OptionParser.new do |opts| options[:startup_item] = :remove end - opts.on( '-h', '--help', 'Display this screen' ) do + opts.on('-h', '--help', 'Display this screen') do puts opts exit end @@ -59,8 +65,8 @@ optparse.parse! if options[:startup_item] require 'startup_item.rb' case options[:startup_item] - when :install then StartupItem.install - when :remove then StartupItem.remove + when :install then StartupItem.install + when :remove then StartupItem.remove end exit end @@ -70,17 +76,20 @@ end # $log = Logger.new(options[:logfile] || STDOUT) $log.level = options[:verbose] ? Logger::DEBUG : Logger::INFO -$log.datetime_format = "%H:%M:%S" +$log.datetime_format = '%H:%M:%S' begin - smtp_server = Thread.new{ SMTPServer.new(options[:smtp_port]) } - pop_server = Thread.new{ POPServer.new(options[:pop3_port]) } + Store.instance.mailbox = options[:mailbox] + smtp_server = Thread.new { SMTPServer.new(options[:smtp_port]) } + options[:mailbox].to_i.times do |i| + pop3_port = options[:pop3_port].to_i + i + pop_server = Thread.new { POPServer.new(pop3_port) } + end smtp_server.join pop_server.join rescue Interrupt - $log.info "Interrupt..." + $log.info 'Interrupt...' rescue Errno::EACCES $log.error "I need root access to open ports #{options[:smtp_port]} and / or #{options[:pop3_port]}. Please sudo #{__FILE__}" end - diff --git a/lib/generic_server.rb b/lib/generic_server.rb index 61885fc..9a11097 100644 --- a/lib/generic_server.rb +++ b/lib/generic_server.rb @@ -21,11 +21,10 @@ # time, so use client.object_id when storing local data class GenericServer - def initialize(options) @port = options[:port] server = TCPServer.open(@port) - $log.info "#{self.class.to_s} listening on port #{@port}" + $log.info "#{self.class} listening on port #{@port}" # Try to increase the buffer to give us some more time to parse incoming data begin @@ -39,7 +38,7 @@ def initialize(options) Thread.start(server.accept) do |client| begin client_addr = client.addr - $log.info "#{self.class.to_s} accepted connection #{client.object_id} from #{client_addr.inspect}" + $log.info "#{self.class} accepted connection #{client.object_id} from #{client_addr.inspect}" greet client # Keep processing commands until somebody closed the connection @@ -47,29 +46,30 @@ def initialize(options) input = client.gets # The first word of a line should contain the command - command = input.to_s.gsub(/ .*/,"").upcase.gsub(/[\r\n]/,"") + command = input.to_s.gsub(/ .*/, '').upcase.gsub(/[\r\n]/, '') $log.debug "#{client.object_id}:#{@port} < #{input}" process(client, command, input) end until client.closed? - $log.info "#{self.class.to_s} closed connection #{client.object_id} with #{client_addr.inspect}" + $log.info "#{self.class} closed connection #{client.object_id} with #{client_addr.inspect}" rescue => detail - $log.error "#{client.object_id}:#{@port} ! #{$!}" + $log.error "#{client.object_id}:#{@port} ! #{$ERROR_INFO}" + $log.error detail client.close end end end end - + # Respond to client by sending back text def respond(client, text) $log.debug "#{client.object_id}:#{@port} > #{text}" client.write text - rescue - $log.error "#{client.object_id}:#{@port} ! #{$!}" + rescue => detail + $log.error "#{client.object_id}:#{@port} ! #{$ERROR_INFO}" + $log.error detail client.close end - -end \ No newline at end of file +end diff --git a/lib/pop_server.rb b/lib/pop_server.rb index 09fe1c1..6ede80e 100644 --- a/lib/pop_server.rb +++ b/lib/pop_server.rb @@ -7,160 +7,176 @@ class POPServer < GenericServer # Create new server listening on port 110 def initialize(port) - super(:port => port) + @clients = {} + super(port: port) end - + # Send a greeting to client def greet(client) - # truncate messages for this session - Store.instance.truncate - - respond(client, true, "Hello there") + # # truncate messages for this session + # # @tvdw - moved this to user method! + # Store.instance.truncate + + respond(client, true, 'Hello there') end - + # Process command def process(client, command, full_data) + address = @clients[client].nil? ? nil : @clients[client] + nr = message_number(full_data, address) case command - when "CAPA" then capa(client) - when "DELE" then dele(client, message_number(full_data)) - when "LIST" then list(client, message_number(full_data)) - when "NOOP" then respond(client, true, "Yup.") - when "PASS" then pass(client, full_data) - when "QUIT" then quit(client) - when "RETR" then retr(client, message_number(full_data)) - when "RSET" then respond(client, true, "Resurrected.") - when "STAT" then stat(client) - when "TOP" then top(client, full_data) - when "UIDL" then uidl(client, message_number(full_data)) - when "USER" then user(client, full_data) - else respond(client, false, "Invalid command.") + when 'CAPA' then capa(client) + when 'DELE' then dele(client, nr, address) + when 'LIST' then list(client, nr, address) + when 'NOOP' then respond(client, true, 'Yup.') + when 'PASS' then pass(client, full_data) + when 'QUIT' then quit(client) + when 'RETR' then retr(client, nr, address) + when 'RSET' then respond(client, true, 'Resurrected.') + when 'STAT' then stat(client, address) + when 'TOP' then top(client, full_data, address) + when 'UIDL' then uidl(client, nr, address) + when 'USER' then user(client, full_data) + else respond(client, false, 'Invalid command.') end + # $log.info "POP Mailbox #{Store.instance.messages}" end - + # Show the client what we can do def capa(client) - respond(client, true, "Here's what I can do:\r\n" + - "USER\r\n" + - "IMPLEMENTATION Bluerail Post Office POP3 Server\r\n" + - ".") + respond(client, true, "Here's what I can do:\r\n" \ + "USER\r\n" \ + "IMPLEMENTATION Bluerail Post Office POP3 Server\r\n" \ + '.') end # Accepts username def user(client, full_data) - respond(client, true, "Password required.") + @clients[client] = full_data.match(/USER (.*)/)[1].chomp if Store.instance.mailbox + + # truncate messages for this session + # @tvdw - Moved truncate from greet, because at that point we do not know which client to truncate yet. + Store.instance.truncate(@clients[client]) + + respond(client, true, 'Password required.') end - + # Authenticates client - def pass(client, full_data) - respond(client, true, "Logged in.") + def pass(client, _full_data) + respond(client, true, 'Logged in.') end - + # Shows list of messages - # + # # When a message id is specified only list # the size of that message - def list(client, message) + def list(client, message, address) if message == :invalid - respond(client, false, "Invalid message number.") + respond(client, false, 'Invalid message number.') elsif message == :all - messages = "" - Store.instance.get.each.with_index do |message, index| - messages << "#{index + 1} #{message.size}\r\n" + messages = '' + Store.instance.get(address).each.with_index do |msg, index| + size = msg.nil? ? 0 : msg.size + messages << "#{index + 1} #{size}\r\n" end respond(client, true, "POP3 clients that break here, they violate STD53.\r\n#{messages}.") else - message_data = Store.instance.get[message - 1] + message_data = Store.instance.get(address)[message - 1] respond(client, true, "#{message} #{message_data.size}") end end - + # Retreives message - def retr(client, message) + def retr(client, message, address) if message == :invalid - respond(client, false, "Invalid message number.") + respond(client, false, 'Invalid message number.') elsif message == :all - respond(client, false, "Invalid message number.") + respond(client, false, 'Invalid message number.') else - message_data = Store.instance.get[message - 1] + message_data = Store.instance.get(address)[message - 1] respond(client, true, "#{message_data.size} octets to follow.\r\n" + message_data + "\r\n.") end end - + # Shows list of message uid # # When a message id is specified only list # the uid of that message - def uidl(client, message) + def uidl(client, message, address) if message == :invalid - respond(client, false, "Invalid message number.") + respond(client, false, 'Invalid message number.') elsif message == :all - messages = "" - Store.instance.get.each.with_index do |message, index| - messages << "#{index + 1} #{message_uid(message)}\r\n" + messages = '' + Store.instance.get(address).each.with_index do |msg, index| + messages << "#{index + 1} #{message_uid(msg)}\r\n" end respond(client, true, "unique-id listing follows.\r\n#{messages}.") else - message_data = Store.instance.get[message - 1] + message_data = Store.instance.get(address)[message - 1] respond(client, true, "#{message} #{message_uid(message_data)}") end end - + # Shows total number of messages and size - def stat(client) - messages = Store.instance.get - total_size = messages.collect{ |m| m.size }.inject(0) { |sum,x| sum+x } + def stat(client, address) + messages = Store.instance.get(address) + + total_size = messages.collect do |m| + m.nil? ? 0 : m.size + end.inject(0) { |sum, x| sum + x } respond(client, true, "#{messages.length} #{total_size}") end - + # Display headers of message - def top(client, full_data) + def top(client, full_data, address) full_data = full_data.split(/TOP\s(\d*)/) messagenum = full_data[1].to_i number_of_lines = full_data[2].to_i - messages = Store.instance.get + messages = Store.instance.get(address) if messages.length >= messagenum && messagenum > 0 - headers = "" + headers = '' line_number = -2 messages[messagenum - 1].split(/\r\n/).each do |line| - line_number = line_number + 1 if line.gsub(/\r\n/, "") == "" || line_number > -2 + line_number += 1 if line.gsub(/\r\n/, '') == '' || line_number > -2 headers += "#{line}\r\n" if line_number < number_of_lines end respond(client, true, "headers follow.\r\n" + headers + "\r\n.") else - respond(client, false, "Invalid message number.") + respond(client, false, 'Invalid message number.') end end - + # Quits def quit(client) - respond(client, true, "Better luck next time.") + respond(client, true, 'Better luck next time.') + @clients.delete(client) client.close end - + # Deletes message - def dele(client, message) + def dele(client, message, address) if message == :invalid - respond(client, false, "Invalid message number.") + respond(client, false, 'Invalid message number.') elsif message == :all - respond(client, false, "Invalid message number.") + respond(client, false, 'Invalid message number.') else - Store.instance.remove(message - 1) - respond(client, true, "Message deleted.") + Store.instance.remove(message - 1, address) + respond(client, true, 'Message deleted.') end end - + protected - + # Returns message number parsed from full_data: # # * No message number => :all # * Message does not exists => :invalid # * valid message number => some fixnum - def message_number(full_data) + def message_number(full_data, address) if /\w*\s*\d/ =~ full_data - messagenum = full_data.gsub(/\D/,"").to_i - messages = Store.instance.get + messagenum = full_data.gsub(/\D/, '').to_i + messages = Store.instance.get(address) if messages.length >= messagenum && messagenum > 0 return messagenum else @@ -170,10 +186,10 @@ def message_number(full_data) return :all end end - + # Respond to client with a POP3 prefix (+OK or -ERR) def respond(client, status, message) - super(client, "#{status ? "+OK" : "-ERR"} #{message}\r\n") + super(client, "#{status ? '+OK' : '-ERR'} #{message}\r\n") end def message_uid(message) diff --git a/lib/smtp_server.rb b/lib/smtp_server.rb index 448675b..ba28bb7 100644 --- a/lib/smtp_server.rb +++ b/lib/smtp_server.rb @@ -5,29 +5,29 @@ class SMTPServer < GenericServer attr_accessor :client_data - + # Create new server listening on port 25 def initialize(port) self.client_data = Hash.new - super(:port => port) + super(port: port) end - + # Send a greeting to client def greet(client) respond(client, 220) end - + # Process command def process(client, command, full_data) case command - when 'DATA' then data(client) - when 'HELO', 'EHLO' then respond(client, 250) - when 'NOOP' then respond(client, 250) - when 'MAIL' then mail_from(client, full_data) - when 'QUIT' then quit(client) - when 'RCPT' then rcpt_to(client, full_data) - when 'RSET' then rset(client) - else begin + when 'DATA' then data(client) + when 'HELO', 'EHLO' then respond(client, 250) + when 'NOOP' then respond(client, 250) + when 'MAIL' then mail_from(client, full_data) + when 'QUIT' then quit(client) + when 'RCPT' then rcpt_to(client, full_data) + when 'RSET' then rset(client) + else begin if get_client_data(client, :sending_data) append_data(client, full_data) else @@ -35,103 +35,104 @@ def process(client, command, full_data) end end end + # $log.info "SMTP Mailbox #{Store.instance.messages}" end - + # Closes connection def quit(client) respond(client, 221) client.close end - + # Stores sender address def mail_from(client, full_data) if /^MAIL FROM:/ =~ full_data.upcase - set_client_data(client, :from, full_data.gsub(/^MAIL FROM:\s*/i,"").gsub(/[\r\n]/,"")) + set_client_data(client, :from, full_data.gsub(/^MAIL FROM:\s*/i, '').gsub(/[\r\n]/, '')) respond(client, 250) else respond(client, 500) end end - + # Stores recepient address def rcpt_to(client, full_data) if /^RCPT TO:/ =~ full_data.upcase - set_client_data(client, :to, full_data.gsub(/^RCPT TO:\s*/i,"").gsub(/[\r\n]/,"")) + set_client_data(client, :to, full_data.gsub(/^RCPT TO:\s*/i, '').gsub(/[\r\n]/, '')) respond(client, 250) else respond(client, 500) end end - + # Markes client sending data def data(client) set_client_data(client, :sending_data, true) - set_client_data(client, :data, "") + set_client_data(client, :data, '') respond(client, 354) end - + # Resets local client store def rset(client) - self.client_data[client.object_id] = Hash.new + client_data[client.object_id] = {} end - + # Adds full_data to incoming mail message # # We'll store the mail when full_data == "." def append_data(client, full_data) - if full_data.gsub(/[\r\n]/,"") == "." + if full_data.gsub(/[\r\n]/, '') == '.' Store.instance.add( get_client_data(client, :from).to_s, get_client_data(client, :to).to_s, get_client_data(client, :data).to_s ) respond(client, 250) - $log.info "Received mail from #{get_client_data(client, :from).to_s} with recipient #{get_client_data(client, :to).to_s}" + $log.info "Received mail from #{get_client_data(client, :from)} with recipient #{get_client_data(client, :to)}" else - self.client_data[client.object_id][:data] << full_data + client_data[client.object_id][:data] << full_data end end - + protected - + # Store key value combination for this client def set_client_data(client, key, value) - self.client_data[client.object_id] = Hash.new unless self.client_data.include?(client.object_id) - self.client_data[client.object_id][key] = value + client_data[client.object_id] = {} unless client_data.include?(client.object_id) + client_data[client.object_id][key] = value end - + # Retreive key from local client store def get_client_data(client, key) - self.client_data[client.object_id][key] if self.client_data.include?(client.object_id) + client_data[client.object_id][key] if client_data.include?(client.object_id) end - + # Respond to client using a standard SMTP response code def respond(client, code) - super(client, "#{code} #{SMTPServer::RESPONSES[code].to_s}\r\n") + super(client, "#{code} #{SMTPServer::RESPONSES[code]}\r\n") end - + # Standard SMTP response codes RESPONSES = { - 500 => "Syntax error, command unrecognized", - 501 => "Syntax error in parameters or arguments", - 502 => "Command not implemented", - 503 => "Bad sequence of commands", - 504 => "Command parameter not implemented", - 211 => "System status, or system help respond", - 214 => "Help message", - 220 => "Bluerail Post Office Service ready", - 221 => "Bluerail Post Office Service closing transmission channel", - 421 => "Bluerail Post Office Service not available,", - 250 => "Requested mail action okay, completed", - 251 => "User not local; will forward to ", - 450 => "Requested mail action not taken: mailbox unavailable", - 550 => "Requested action not taken: mailbox unavailable", - 451 => "Requested action aborted: error in processing", - 551 => "User not local; please try ", - 452 => "Requested action not taken: insufficient system storage", - 552 => "Requested mail action aborted: exceeded storage allocation", - 553 => "Requested action not taken: mailbox name not allowed", - 354 => "Start mail input; end with .", - 554 => "Transaction failed" + 500 => 'Syntax error, command unrecognized', + 501 => 'Syntax error in parameters or arguments', + 502 => 'Command not implemented', + 503 => 'Bad sequence of commands', + 504 => 'Command parameter not implemented', + 211 => 'System status, or system help respond', + 214 => 'Help message', + 220 => 'Bluerail Post Office Service ready', + 221 => 'Bluerail Post Office Service closing transmission channel', + 421 => 'Bluerail Post Office Service not available,', + 250 => 'Requested mail action okay, completed', + 251 => 'User not local; will forward to ', + 450 => 'Requested mail action not taken: mailbox unavailable', + 550 => 'Requested action not taken: mailbox unavailable', + 451 => 'Requested action aborted: error in processing', + 551 => 'User not local; please try ', + 452 => 'Requested action not taken: insufficient system storage', + 552 => 'Requested mail action aborted: exceeded storage allocation', + 553 => 'Requested action not taken: mailbox name not allowed', + 354 => 'Start mail input; end with .', + 554 => 'Transaction failed' }.freeze end diff --git a/lib/store.rb b/lib/store.rb index 2837379..861190c 100644 --- a/lib/store.rb +++ b/lib/store.rb @@ -5,29 +5,50 @@ class Store include Singleton - attr_accessor :messages - + attr_accessor :messages, :mailbox + def initialize - self.messages = [] + @messages = {} + @messages[:default] = [] + @mailbox = false end # Returns array of messages - def get - return messages + def get(key = nil) + if @mailbox && !key.nil? + @messages[key] = [] unless @messages[key].is_a?(Array) + return @messages[key] + else + return @messages[:default] + end end - + # Saves message in storage - def add(mail_from, rcpt_to, message_data) - messages.push message_data + def add(_mail_from, rcpt_to, message_data) + key = rcpt_to.gsub(/[<>]/, '') + if @mailbox + @messages[key] = [] unless @messages[key].is_a?(Array) + @messages[key].push message_data + else + @messages[:default].push message_data + end end - + # Removes message from storage - def remove(index) - self.messages[index] = nil + def remove(index, key = nil) + if @mailbox && !key.nil? + @messages[key][index] = nil + else + @messages[:default][index] = nil + end end - + # Remove empty messages - def truncate - self.messages = self.messages.reject{ |message| message.nil? } + def truncate(key = nil) + if @mailbox && !key.nil? + @messages[key] = @messages[key].reject(&:nil?) if @messages[key].is_a?(Array) + else + @messages[:default] = @messages[:default].reject(&:nil?) + end end -end \ No newline at end of file +end diff --git a/test/pop_client.rb b/test/pop_client.rb new file mode 100644 index 0000000..599ba66 --- /dev/null +++ b/test/pop_client.rb @@ -0,0 +1,15 @@ +# A really simple POP client to test post_office. +# It POPs mail, print the mail ObjectID and then delete it. +# +# To use run: `ruby pop_client.rb "address@domain.com"` + +require 'net/pop' + +Net::POP3.start('localhost', ARGV[1], ARGV[0], 'boguspassword') do |pop| + unless pop.mails.empty? + pop.each_mail do |mail| + p mail + mail.delete + end + end +end diff --git a/test/smtp_client.rb b/test/smtp_client.rb new file mode 100644 index 0000000..3fa86d8 --- /dev/null +++ b/test/smtp_client.rb @@ -0,0 +1,17 @@ +# A really simple SMTP client to test post_office. +# It sends mail, print the mail ObjectID and then delete it. +# +# To use run: `ruby pop_client.rb "address@domain.com"` +require 'mail' + +Mail.defaults do + delivery_method :smtp, address: 'localhost', port: 2525 +end +subject = ARGV[2].nil? ? 'test email' : ARGV[2] +from = ARGV[1].nil? ? 'from@address.com' : ARGV[1] +to = ARGV[0].nil? ? 'to@address.com' : ARGV[0] +Mail.deliver do + from from + to to + subject subject +end