From 7669b2b18b0153bf78c2e89e4e57aaf38ed9ba5f Mon Sep 17 00:00:00 2001 From: Matt Palmer Date: Tue, 31 Dec 2024 11:43:46 +1100 Subject: [PATCH] Make PostOffice more idiomatic to use as a library --- bin/post_office | 26 ++--- lib/config_file.rb | 24 ---- lib/generic_server.rb | 75 ------------ lib/pop_server.rb | 182 ----------------------------- lib/post_office/config_file.rb | 26 +++++ lib/post_office/generic_server.rb | 83 ++++++++++++++ lib/post_office/pop_server.rb | 184 ++++++++++++++++++++++++++++++ lib/post_office/smtp_server.rb | 151 ++++++++++++++++++++++++ lib/post_office/startup_item.rb | 49 ++++++++ lib/post_office/store.rb | 35 ++++++ lib/smtp_server.rb | 144 ----------------------- lib/startup_item.rb | 47 -------- lib/store.rb | 33 ------ 13 files changed, 541 insertions(+), 518 deletions(-) delete mode 100644 lib/config_file.rb delete mode 100644 lib/generic_server.rb delete mode 100644 lib/pop_server.rb create mode 100644 lib/post_office/config_file.rb create mode 100644 lib/post_office/generic_server.rb create mode 100644 lib/post_office/pop_server.rb create mode 100644 lib/post_office/smtp_server.rb create mode 100644 lib/post_office/startup_item.rb create mode 100644 lib/post_office/store.rb delete mode 100644 lib/smtp_server.rb delete mode 100644 lib/startup_item.rb delete mode 100644 lib/store.rb diff --git a/bin/post_office b/bin/post_office index 96ec5cf..a2fe313 100755 --- a/bin/post_office +++ b/bin/post_office @@ -4,9 +4,9 @@ $LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib' require 'optparse' require 'logger' require 'thread' -require 'smtp_server.rb' -require 'pop_server.rb' -require 'config_file.rb' +require 'post_office/smtp_server' +require 'post_office/pop_server' +require 'post_office/config_file' options = ConfigFile.detect.read @@ -57,10 +57,10 @@ optparse.parse! # OS X Startup Item # if options[:startup_item] - require 'startup_item.rb' + require 'post_office/startup_item' case options[:startup_item] - when :install then StartupItem.install - when :remove then StartupItem.remove + when :install then PostOffice::StartupItem.install + when :remove then PostOffice::StartupItem.remove end exit end @@ -68,19 +68,19 @@ end # # Create our logger # -$log = Logger.new(options[:logfile] || STDOUT) -$log.level = options[:verbose] ? Logger::DEBUG : Logger::INFO -$log.datetime_format = "%H:%M:%S" +log = Logger.new(options[:logfile] || STDOUT) +log.level = options[:verbose] ? Logger::DEBUG : Logger::INFO +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]) } + smtp_server = Thread.new{ PostOffice::SMTPServer.new(options[:smtp_port], logger: log).run } + pop_server = Thread.new{ PostOffice::POPServer.new(options[:pop3_port], logger: log) } 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__}" + 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/config_file.rb b/lib/config_file.rb deleted file mode 100644 index aef7e61..0000000 --- a/lib/config_file.rb +++ /dev/null @@ -1,24 +0,0 @@ -require 'json' - -class ConfigFile - USER_CONFIG_DIR = ENV.fetch('XDG_CONFIG_HOME', ENV['HOME'] + '/.config') - SYSTEM_CONFIG_DIR = '/etc' - CONFIG_DIRS = [USER_CONFIG_DIR, SYSTEM_CONFIG_DIR] - attr_reader :filename - - def initialize(filename) - @filename = filename - end - - def self.detect - filename = - CONFIG_DIRS.map { |dir| "#{dir}/post_office/config.json" } - .detect { |file| File.exist? file } - new(filename) - end - - def read - return {} if @filename.nil? - JSON.parse(File.read(@filename), symbolize_names: true) - end -end diff --git a/lib/generic_server.rb b/lib/generic_server.rb deleted file mode 100644 index 61885fc..0000000 --- a/lib/generic_server.rb +++ /dev/null @@ -1,75 +0,0 @@ -require 'socket' -require 'thread' - -# This class starts a generic server, accepting connections -# on options[:port] -# -# When extending this class make sure you: -# -# * def greet(client) -# * def process(client, command, full_data) -# * client.close when you're done -# -# You can respond to the client using: -# -# * respond(client, text) -# -# The command given to process equals the first word -# of full_data in upcase, e.g. QUIT or LIST -# -# It's possible for multiple clients to connect at the same -# 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}" - - # Try to increase the buffer to give us some more time to parse incoming data - begin - server.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 1024 * 1024) - rescue - # then try it using our available buffer - end - - # Accept connections until infinity and beyond - loop do - 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}" - greet client - - # Keep processing commands until somebody closed the connection - begin - input = client.gets - - # The first word of a line should contain the command - 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}" - rescue => detail - $log.error "#{client.object_id}:#{@port} ! #{$!}" - 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} ! #{$!}" - client.close - end - -end \ No newline at end of file diff --git a/lib/pop_server.rb b/lib/pop_server.rb deleted file mode 100644 index 09fe1c1..0000000 --- a/lib/pop_server.rb +++ /dev/null @@ -1,182 +0,0 @@ -require 'generic_server.rb' -require 'store.rb' -require 'digest/md5' - -# Basic POP server - -class POPServer < GenericServer - # Create new server listening on port 110 - def initialize(port) - 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") - end - - # Process command - def process(client, command, full_data) - 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.") - end - 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" + - ".") - end - - # Accepts username - def user(client, full_data) - respond(client, true, "Password required.") - end - - # Authenticates client - 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) - if message == :invalid - 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" - end - respond(client, true, "POP3 clients that break here, they violate STD53.\r\n#{messages}.") - else - message_data = Store.instance.get[message - 1] - respond(client, true, "#{message} #{message_data.size}") - end - end - - # Retreives message - def retr(client, message) - if message == :invalid - respond(client, false, "Invalid message number.") - elsif message == :all - respond(client, false, "Invalid message number.") - else - message_data = Store.instance.get[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) - if message == :invalid - 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" - end - respond(client, true, "unique-id listing follows.\r\n#{messages}.") - else - message_data = Store.instance.get[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 } - respond(client, true, "#{messages.length} #{total_size}") - end - - # Display headers of message - def top(client, full_data) - 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 - if messages.length >= messagenum && messagenum > 0 - 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 - 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.") - end - end - - # Quits - def quit(client) - respond(client, true, "Better luck next time.") - client.close - end - - # Deletes message - def dele(client, message) - if message == :invalid - respond(client, false, "Invalid message number.") - elsif message == :all - respond(client, false, "Invalid message number.") - else - Store.instance.remove(message - 1) - 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) - if /\w*\s*\d/ =~ full_data - messagenum = full_data.gsub(/\D/,"").to_i - messages = Store.instance.get - if messages.length >= messagenum && messagenum > 0 - return messagenum - else - return :invalid - end - else - 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") - end - - def message_uid(message) - Digest::MD5.hexdigest(message) - end -end diff --git a/lib/post_office/config_file.rb b/lib/post_office/config_file.rb new file mode 100644 index 0000000..3b35d99 --- /dev/null +++ b/lib/post_office/config_file.rb @@ -0,0 +1,26 @@ +require 'json' + +module PostOffice + class ConfigFile + USER_CONFIG_DIR = ENV.fetch('XDG_CONFIG_HOME', ENV['HOME'] + '/.config') + SYSTEM_CONFIG_DIR = '/etc' + CONFIG_DIRS = [USER_CONFIG_DIR, SYSTEM_CONFIG_DIR] + attr_reader :filename + + def initialize(filename) + @filename = filename + end + + def self.detect + filename = + CONFIG_DIRS.map { |dir| "#{dir}/post_office/config.json" } + .detect { |file| File.exist? file } + new(filename) + end + + def read + return {} if @filename.nil? + JSON.parse(File.read(@filename), symbolize_names: true) + end + end +end diff --git a/lib/post_office/generic_server.rb b/lib/post_office/generic_server.rb new file mode 100644 index 0000000..321e413 --- /dev/null +++ b/lib/post_office/generic_server.rb @@ -0,0 +1,83 @@ +require 'logger' +require 'socket' +require 'thread' + +module PostOffice + # This class starts a generic server, accepting connections + # on options[:port] + # + # When extending this class make sure you: + # + # * def greet(client) + # * def process(client, command, full_data) + # * client.close when you're done + # + # You can respond to the client using: + # + # * respond(client, text) + # + # The command given to process equals the first word + # of full_data in upcase, e.g. QUIT or LIST + # + # It's possible for multiple clients to connect at the same + # time, so use client.object_id when storing local data + + class GenericServer + attr_reader :port + + def initialize(options) + port = options[:port] + @logger = options[:logger] || Logger.new("/dev/null") + @server = TCPServer.open(port) + @port = @server.addr[1] + + @logger.info "#{self.class.to_s} listening on port #{@port}" + + # Try to increase the buffer to give us some more time to parse incoming data + begin + @server.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 1024 * 1024) + rescue + # then try it using our available buffer + end + end + + def run + # Accept connections until infinity and beyond + loop do + Thread.start(@server.accept) do |client| + begin + client_addr = client.addr + @logger.info "#{self.class.to_s} accepted connection #{client.object_id} from #{client_addr.inspect}" + greet client + + # Keep processing commands until somebody closed the connection + begin + input = client.gets + + # The first word of a line should contain the command + command = input.to_s.gsub(/ .*/,"").upcase.gsub(/[\r\n]/,"") + + @logger.debug "#{client.object_id}:#{@port} < #{input}" + + process(client, command, input) + + end until client.closed? + @logger.info "#{self.class.to_s} closed connection #{client.object_id} with #{client_addr.inspect}" + rescue => detail + @logger.error "#{client.object_id}:#{@port} ! #{$!}" + client.close + end + end + end + end + + # Respond to client by sending back text + def respond(client, text) + @logger.debug "#{client.object_id}:#{@port} > #{text}" + client.write text + rescue + @logger.error "#{client.object_id}:#{@port} ! #{$!}" + client.close + end + end +end diff --git a/lib/post_office/pop_server.rb b/lib/post_office/pop_server.rb new file mode 100644 index 0000000..67c40b8 --- /dev/null +++ b/lib/post_office/pop_server.rb @@ -0,0 +1,184 @@ +require_relative './generic_server' +require_relative './store' +require 'digest/md5' + +module PostOffice + # Basic POP server + + class POPServer < GenericServer + # Create new server listening on port 110 + def initialize(port) + 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") + end + + # Process command + def process(client, command, full_data) + 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.") + end + 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" + + ".") + end + + # Accepts username + def user(client, full_data) + respond(client, true, "Password required.") + end + + # Authenticates client + 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) + if message == :invalid + 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" + end + respond(client, true, "POP3 clients that break here, they violate STD53.\r\n#{messages}.") + else + message_data = Store.instance.get[message - 1] + respond(client, true, "#{message} #{message_data.size}") + end + end + + # Retreives message + def retr(client, message) + if message == :invalid + respond(client, false, "Invalid message number.") + elsif message == :all + respond(client, false, "Invalid message number.") + else + message_data = Store.instance.get[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) + if message == :invalid + 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" + end + respond(client, true, "unique-id listing follows.\r\n#{messages}.") + else + message_data = Store.instance.get[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 } + respond(client, true, "#{messages.length} #{total_size}") + end + + # Display headers of message + def top(client, full_data) + 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 + if messages.length >= messagenum && messagenum > 0 + 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 + 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.") + end + end + + # Quits + def quit(client) + respond(client, true, "Better luck next time.") + client.close + end + + # Deletes message + def dele(client, message) + if message == :invalid + respond(client, false, "Invalid message number.") + elsif message == :all + respond(client, false, "Invalid message number.") + else + Store.instance.remove(message - 1) + 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) + if /\w*\s*\d/ =~ full_data + messagenum = full_data.gsub(/\D/,"").to_i + messages = Store.instance.get + if messages.length >= messagenum && messagenum > 0 + return messagenum + else + return :invalid + end + else + 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") + end + + def message_uid(message) + Digest::MD5.hexdigest(message) + end + end +end diff --git a/lib/post_office/smtp_server.rb b/lib/post_office/smtp_server.rb new file mode 100644 index 0000000..78f90d5 --- /dev/null +++ b/lib/post_office/smtp_server.rb @@ -0,0 +1,151 @@ +require 'logger' + +require_relative './generic_server' +require_relative './store' + +module PostOffice + # Basic SMTP server + + class SMTPServer < GenericServer + attr_accessor :client_data, :messages + + # Create new server listening on port 25 + def initialize(port, logger: Logger.new("/dev/null")) + self.client_data = Hash.new + self.messages = [] + super(port: port, logger: logger) + 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) + when 'AUTH' then auth(client) + else begin + if get_client_data(client, :sending_data) + append_data(client, full_data) + else + respond(client, 500) + end + end + end + 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]/,"")) + 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]/,"")) + 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, "") + respond(client, 354) + end + + # Resets local client store + def rset(client) + self.client_data[client.object_id] = Hash.new + respond(client, 250) + end + + # Authenticates client + def auth(client) + respond(client, 235) + 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]/,"") == "." + Store.instance.add( + get_client_data(client, :from).to_s, + get_client_data(client, :to).to_s, + get_client_data(client, :data).to_s + ) + self.messages << { from: get_client_data(client, :from).to_s, to: get_client_data(client, :to).to_s, data: get_client_data(client, :data).to_s } + respond(client, 250) + @logger.info "Received mail from #{get_client_data(client, :from).to_s} with recipient #{get_client_data(client, :to).to_s}" + else + self.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 + 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) + end + + # Respond to client using a standard SMTP response code + def respond(client, code) + super(client, "#{code} #{SMTPServer::RESPONSES[code].to_s}\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", + 235 => "Authentication successful", + 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 +end diff --git a/lib/post_office/startup_item.rb b/lib/post_office/startup_item.rb new file mode 100644 index 0000000..8748662 --- /dev/null +++ b/lib/post_office/startup_item.rb @@ -0,0 +1,49 @@ +module PostOffice + class StartupItem + def self.install + target_path = File.join("/","Library","StartupItems") + + # We cannot install this twice + if File.exists?(File.join(target_path,"PostOffice")) + puts "PostOffice Startup Item is already installed." + exit + end + + # We need /usr/bin/post_officed for this startup item to function + unless File.exists?(File.join("/","usr","bin","post_officed")) + puts "Error: missing /usr/bin/post_officed. Have you gem install post_office?" + exit + end + + puts "Installing Post Office Mac OS X Startup Item..." + + # Make sure /Library/StartupItems exists + FileUtils.mkdir_p(target_path) + + source = File.join(File.dirname(__FILE__), "..", "startup_item", "PostOffice") + destination = File.join(target_path, "PostOffice") + + FileUtils.cp_r(source, destination) + + puts "Successfully installed Startup Item!" + end + + def self.remove + target_path = File.join("/","Library","StartupItems") + + unless File.exists?(File.join(target_path,"PostOffice")) + puts "PostOffice Startup Item not installed." + exit + end + + puts "removing Post Office Mac OS X Startup Item..." + FileUtils.rm_rf(File.join(target_path, "PostOffice")) + + unless File.exists?(File.join(target_path,"PostOffice")) + puts "Successfully removed Startup Item!" + else + puts "Unable to remove Startup Item: hae you used sudo?" + end + end + end +end diff --git a/lib/post_office/store.rb b/lib/post_office/store.rb new file mode 100644 index 0000000..dbe1f9d --- /dev/null +++ b/lib/post_office/store.rb @@ -0,0 +1,35 @@ +require 'fileutils' +require 'singleton' + +module PostOffice + # Message storage + + class Store + include Singleton + attr_accessor :messages + + def initialize + self.messages = [] + end + + # Returns array of messages + def get + return messages + end + + # Saves message in storage + def add(mail_from, rcpt_to, message_data) + messages.push message_data + end + + # Removes message from storage + def remove(index) + self.messages[index] = nil + end + + # Remove empty messages + def truncate + self.messages = self.messages.reject{ |message| message.nil? } + end + end +end diff --git a/lib/smtp_server.rb b/lib/smtp_server.rb deleted file mode 100644 index aa79167..0000000 --- a/lib/smtp_server.rb +++ /dev/null @@ -1,144 +0,0 @@ -require 'generic_server.rb' -require 'store.rb' - -# Basic SMTP server - -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) - 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) - when 'AUTH' then auth(client) - else begin - if get_client_data(client, :sending_data) - append_data(client, full_data) - else - respond(client, 500) - end - end - end - 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]/,"")) - 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]/,"")) - 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, "") - respond(client, 354) - end - - # Resets local client store - def rset(client) - self.client_data[client.object_id] = Hash.new - end - - # Authenticates client - def auth(client) - respond(client, 235) - 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]/,"") == "." - 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}" - else - self.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 - 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) - end - - # Respond to client using a standard SMTP response code - def respond(client, code) - super(client, "#{code} #{SMTPServer::RESPONSES[code].to_s}\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", - 235 => "Authentication successful", - 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/startup_item.rb b/lib/startup_item.rb deleted file mode 100644 index 7df4f86..0000000 --- a/lib/startup_item.rb +++ /dev/null @@ -1,47 +0,0 @@ -class StartupItem - def self.install - target_path = File.join("/","Library","StartupItems") - - # We cannot install this twice - if File.exists?(File.join(target_path,"PostOffice")) - puts "PostOffice Startup Item is already installed." - exit - end - - # We need /usr/bin/post_officed for this startup item to function - unless File.exists?(File.join("/","usr","bin","post_officed")) - puts "Error: missing /usr/bin/post_officed. Have you gem install post_office?" - exit - end - - puts "Installing Post Office Mac OS X Startup Item..." - - # Make sure /Library/StartupItems exists - FileUtils.mkdir_p(target_path) - - source = File.join(File.dirname(__FILE__), "..", "startup_item", "PostOffice") - destination = File.join(target_path, "PostOffice") - - FileUtils.cp_r(source, destination) - - puts "Successfully installed Startup Item!" - end - - def self.remove - target_path = File.join("/","Library","StartupItems") - - unless File.exists?(File.join(target_path,"PostOffice")) - puts "PostOffice Startup Item not installed." - exit - end - - puts "removing Post Office Mac OS X Startup Item..." - FileUtils.rm_rf(File.join(target_path, "PostOffice")) - - unless File.exists?(File.join(target_path,"PostOffice")) - puts "Successfully removed Startup Item!" - else - puts "Unable to remove Startup Item: hae you used sudo?" - end - end -end diff --git a/lib/store.rb b/lib/store.rb deleted file mode 100644 index 2837379..0000000 --- a/lib/store.rb +++ /dev/null @@ -1,33 +0,0 @@ -require 'fileutils' -require 'singleton' - -# Message storage - -class Store - include Singleton - attr_accessor :messages - - def initialize - self.messages = [] - end - - # Returns array of messages - def get - return messages - end - - # Saves message in storage - def add(mail_from, rcpt_to, message_data) - messages.push message_data - end - - # Removes message from storage - def remove(index) - self.messages[index] = nil - end - - # Remove empty messages - def truncate - self.messages = self.messages.reject{ |message| message.nil? } - end -end \ No newline at end of file