├── .rspec ├── spec ├── spec_helper.rb ├── backend_spec.rb └── lmtp_spec.rb ├── .gitignore ├── lib ├── received │ ├── version.rb │ ├── backend │ │ ├── mongodb.rb │ │ ├── base.rb │ │ └── sidekiq.rb │ ├── connection.rb │ ├── server.rb │ └── lmtp.rb └── received.rb ├── Gemfile ├── Rakefile ├── received.gemspec ├── LICENSE ├── bin └── received └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format progress 3 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'received' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | .yardoc 4 | Gemfile.lock 5 | pkg/* 6 | -------------------------------------------------------------------------------- /lib/received/version.rb: -------------------------------------------------------------------------------- 1 | module Received 2 | VERSION = "0.5.1" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem 'rspec-core', '~> 2.9' 4 | gem 'rspec-mocks', '~> 2.9' 5 | gem 'rspec-expectations', '~> 2.9' 6 | 7 | gemspec 8 | -------------------------------------------------------------------------------- /lib/received.rb: -------------------------------------------------------------------------------- 1 | module Received 2 | def self.logger=(l) 3 | @logger = l 4 | end 5 | 6 | def self.logger 7 | @logger 8 | end 9 | end 10 | 11 | require 'received/backend/base' 12 | require 'received/server' -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require 'rspec/core/rake_task' 3 | 4 | desc 'Default: run specs.' 5 | task :default => :spec 6 | 7 | desc "Run specs" 8 | RSpec::Core::RakeTask.new 9 | 10 | Bundler::GemHelper.install_tasks 11 | -------------------------------------------------------------------------------- /spec/backend_spec.rb: -------------------------------------------------------------------------------- 1 | require 'received/backend/base' 2 | require 'received/backend/mongodb' 3 | 4 | describe 'Observable' do 5 | subject { Received::Backend::Mongodb.new('host' => '127.0.0.1', 'database' => 'spec', 'collection' => 'inbox') } 6 | 7 | it "notifies observers" do 8 | observer = mock('observer', :after_save => true) 9 | Received::Backend::Base.add_observer(observer) 10 | mail = { 11 | from: 'spec@example.com', 12 | rcpt: ['to1@example.com', 'to2@example.com'], 13 | body: 'spec' 14 | } 15 | observer.should_receive(:after_save) 16 | subject.store(mail) 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /lib/received/backend/mongodb.rb: -------------------------------------------------------------------------------- 1 | require 'mongo' 2 | 3 | module Received 4 | module Backend 5 | class Mongodb < Base 6 | 7 | # Initialize MongoDB storage backend 8 | # 9 | # @param [Hash] params 10 | # @option params [String] host 11 | # @option params [String] database 12 | # @option params [String] collection 13 | def initialize(params) 14 | db = Mongo::Connection.new(params['host']).db(params['database']) 15 | @coll = db.collection(params['collection']) 16 | end 17 | 18 | # Store mail in MongoDB 19 | # 20 | # @param [Hash] mail 21 | # @return [ObjectId] object_id 22 | def store(mail) 23 | mail = mail.merge(:ts => Time.now.to_i, :body => BSON::Binary.new(mail[:body])) 24 | @coll.insert(mail, :w => 1, :fsync => true).tap do |result| 25 | notify_observers(:after_save, result) if result 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /received.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "received/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "received" 7 | s.version = Received::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Roman Shterenzon"] 10 | s.email = ["romanbsd@yahoo.com"] 11 | s.homepage = "https://github.com/romanbsd/received" 12 | s.license = "MIT" 13 | s.summary = %q{Receive mail from Postfix and store it somewhere} 14 | s.description = %q{Currently stores received mail in MongoDB or Redis} 15 | 16 | s.files = Dir["lib/**/*", "*.gemspec", "LICENSE*", "README*"] 17 | s.executables = Dir["bin/*"].map { |f| File.basename(f) } 18 | s.require_paths = ["lib"] 19 | s.add_runtime_dependency 'daemons', '~> 1.1' 20 | s.add_runtime_dependency 'eventmachine', '~> 1.0' 21 | s.add_runtime_dependency 'mongo', '~> 1.3' 22 | s.add_runtime_dependency 'bson_ext', '~> 1.3' 23 | s.add_runtime_dependency 'redis', '~> 4.1' 24 | s.add_runtime_dependency 'charlock_holmes', '~> 0.7' 25 | end 26 | -------------------------------------------------------------------------------- /lib/received/backend/base.rb: -------------------------------------------------------------------------------- 1 | module Received 2 | module Backend 3 | class Base 4 | # Stores the data 5 | # 6 | # @abstract 7 | # @param [Hash] mail 8 | # @option mail [String] :from 9 | # @option mail [Array] :rcpt 10 | # @option mail [String] :body 11 | def store(mail) 12 | raise NotImplementedError 13 | end 14 | 15 | def notify_observers(*args) 16 | self.class.notify_observers(*args) 17 | end 18 | 19 | class << self 20 | def observers 21 | @@observers ||= [] 22 | end 23 | 24 | def add_observer(observer) 25 | observers.push(observer) 26 | end 27 | 28 | def remove_observer(observer) 29 | observers.delete(observer) 30 | end 31 | 32 | def notify_observers(method, payload) 33 | observers.each do |observer| 34 | if observer.respond_to?(method) 35 | observer.send(method, payload) rescue nil 36 | end 37 | end 38 | end 39 | end 40 | 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Roman Shterenzon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/received/connection.rb: -------------------------------------------------------------------------------- 1 | require 'eventmachine' 2 | require 'received/lmtp' 3 | 4 | module Received 5 | class Connection < EM::Connection 6 | 7 | def initialize(server, backend) 8 | @server, @backend = server, backend 9 | @proto = LMTP.new(self) 10 | end 11 | 12 | def post_init 13 | logger.debug "new connection" 14 | @proto.start! 15 | end 16 | 17 | def receive_data(data) 18 | logger.debug {"receiving data: #{data.inspect}"} 19 | @proto.on_data(data) 20 | end 21 | 22 | def send_data(data) 23 | logger.debug {"sending data: #{data.inspect}"} 24 | super 25 | end 26 | 27 | # Client disconnected 28 | def unbind 29 | logger.debug "connection closed" 30 | @server.remove_connection(self) 31 | end 32 | 33 | # Callback, called by protocol handler 34 | # 35 | # @param [Hash] mail 36 | # @see Received::Backend::Base#store 37 | # @return [Boolean] saving succeeded 38 | def mail_received(mail) 39 | begin 40 | if insert_id = @backend.store(mail) 41 | logger.info "stored mail from: #{mail[:from]} (#{insert_id})" 42 | return true 43 | else 44 | logger.error "saving of mail from #{mail[:from]} failed" 45 | end 46 | false 47 | rescue Exception => e 48 | logger.error "saving of mail from #{mail[:from]} failed with: #{e.message}" 49 | logger.error e.backtrace.join("\n") 50 | false 51 | end 52 | end 53 | 54 | def logger 55 | Received.logger 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/received/backend/sidekiq.rb: -------------------------------------------------------------------------------- 1 | require 'charlock_holmes' 2 | require 'json' 3 | require 'redis' 4 | require 'securerandom' 5 | 6 | module Received 7 | module Backend 8 | class Sidekiq < Base 9 | 10 | # Initialize Sidekiq storage backend 11 | # 12 | # @param [Hash] params 13 | # @option params [String] redis_url 14 | # @option params [String] namespace 15 | # @option params [String] sentinels 16 | # @option params [String] queue 17 | # @option params [String] worker 18 | def initialize(params) 19 | @queue = params.fetch('queue', 'default') 20 | @namespace = params['namespace'] 21 | @worker = params['worker'] 22 | 23 | @client = ::Redis.new({ 24 | url: params.fetch('redis_url', 'redis://localhost:6379'), 25 | sentinels: params['sentinels'], 26 | }.compact) 27 | end 28 | 29 | # Store mail in Redis 30 | # 31 | # @param [Hash] mail 32 | # @return [String] job id 33 | def store(mail) 34 | item = item_for(mail[:body]) 35 | 36 | @client.lpush(queue_key, JSON.generate(item)) 37 | notify_observers(:after_save, item) 38 | 39 | item['jid'] 40 | end 41 | 42 | protected 43 | 44 | def queue_key 45 | [@namespace, 'queue', @queue].compact.join(':') 46 | end 47 | 48 | def item_for(message) 49 | { 50 | 'class' => @worker, 51 | 'args' => [utf8_encode_message(message)], 52 | 'queue' => @queue, 53 | 'jid' => SecureRandom.hex(12), 54 | 'retry' => false, 55 | 'enqueued_at' => Time.now.to_f, 56 | } 57 | end 58 | 59 | def utf8_encode_message(message) 60 | message = message.dup 61 | 62 | message.force_encoding('UTF-8') 63 | return message if message.valid_encoding? 64 | 65 | detection = CharlockHolmes::EncodingDetector.detect(message) 66 | return message unless detection && detection[:encoding] 67 | 68 | # Convert non-UTF-8 body UTF-8 so it can be dumped as JSON. 69 | CharlockHolmes::Converter.convert(message, detection[:encoding], 'UTF-8') 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /bin/received: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'rubygems' 3 | require 'logger' 4 | require 'optparse' 5 | $:<< File.expand_path('../../lib', __FILE__) 6 | require 'received' 7 | require 'daemons' 8 | 9 | options = {} 10 | OptionParser.new do |opts| 11 | opts.banner = 'Usage: received [options]' 12 | opts.on('-c', '--config FILE', 'Config file name (required)') {|v| options[:config] = v} 13 | opts.on('-b', '--backend BACKEND', [:mongodb, :sidekiq], 'Backend (default: mongodb)') {|v| options[:backend] = v} 14 | opts.on('-d', '--daemonize', 'Become a daemon') {|v| options[:daemon] = v} 15 | opts.on('-s', '--unix-socket PATH', 'Use UNIX socket') {|v| options[:unix_socket] = v} 16 | opts.on('-p', '--port NUM', 'Listen to TCP port') {|v| options[:port] = v.to_i} 17 | opts.on('-i', '--host NAME', 'Bind to this IP (default: 127.0.0.1)') {|v| options[:host] = v} 18 | opts.on('-a', '--piddir PATH', 'Directory for pid file (default: /var/run/received)') {|v| options[:dir] = v} 19 | opts.on('-l', '--log FILE', 'Log file name (default: /var/log/received.log)') {|v| options[:logfile] = v} 20 | opts.on('-u', '--user NAME', 'Effective user when daemon (default: nobody)') {|v| options[:user] = v} 21 | opts.on('-g', '--group NAME', 'Effective group when daemon (default: nobody)') {|v| options[:group] = v} 22 | opts.on('-r', '--require FILE1,FILE2,...', 'Require ruby file(s)') {|v| options[:require] = v} 23 | opts.on('-v', '--verbose', 'Verbose logging') {options[:level] = Logger::DEBUG} 24 | opts.on_tail('-h', '--help', 'Show this message') do 25 | puts opts 26 | exit 27 | end 28 | end.parse! 29 | 30 | raise "Config file is required, please provide with -c config.yml" unless options[:config] 31 | 32 | # Default backend 33 | options[:backend] ||= 'mongodb' 34 | options[:dir] ||= '/var/run/received' 35 | 36 | options[:logger] = Logger.new(options[:logfile] || $stderr).tap do |logger| 37 | logger.level = options[:level] || Logger::INFO 38 | end 39 | 40 | ontop = !options.delete(:daemon) 41 | params = { 42 | :ontop => ontop, 43 | :app_name => 'received', 44 | :log_output => true, 45 | :dir_mode => :normal, 46 | :dir => options[:dir] 47 | } 48 | params[:no_pidfiles] = true if ontop 49 | 50 | # Drop privileges if started as superuser 51 | params.merge!(:user => options[:user] || 'nobody', :group => options[:group] || 'nobody') if Process.uid == 0 52 | Daemons.daemonize(params) 53 | File.umask(0007) 54 | 55 | if files = options[:require] 56 | files.split(',').each { |file| require file } 57 | end 58 | 59 | server = Received::Server.new(options) 60 | %w(TERM INT).each do |sig| 61 | Signal.trap(sig) {server.stop} 62 | end 63 | 64 | server.serve! 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ReceiveD 2 | ======== 3 | 4 | *** 5 | I don't use it anymore, but when it was used, it was used in production. 6 | I consider it stable, rather than obsolete. 7 | *** 8 | 9 | ReceiveD is yet another way for receiving mail with Rails. 10 | Why have yet another subsystem (like IMAP), when you can deliver the mail 11 | directly to your data store? 12 | 13 | ReceiveD is almost [RFC2033][1] compliant LMTP server built around 14 | [eventmachine][2] and as such should be quite fast. 15 | 16 | The receive daemon will listen on TCP or UNIX socket, and write the mail 17 | to the backend storage. 18 | 19 | Currently only [MongoDB][3] and [Sidekiq][6]/Redis is supported, but writing another backend 20 | (MySQL, Redis, etc.) is trivial. 21 | 22 | 23 | Installation 24 | ------------ 25 | `sudo gem install received` 26 | 27 | Modify your [Postfix][4] configuration to deliver mail via LMTP to TCP or UNIX socket. 28 | 29 | Example main.cf: 30 | 31 | virtual_transport = lmtp:192.168.2.106:1111 32 | virtual_mailbox_domains = example.com 33 | 34 | Create a YAML configuration file with parameters for the selected backend. 35 | 36 | The default environment is *production*, but you can specify other environment 37 | using RAILS_ENV environment variable. 38 | In this case, make sure you have the relevant key in your configuration file. 39 | 40 | ### MongoDB 41 | 42 | Create a YAML configuration file which has the following parameters: 43 | 44 | {'production'=>{'host'=>hostname, 'database'=>db, 'collection'=>col}} 45 | 46 | The mongoid.yml will do, just add the name of collection, i.e. 47 | 48 | production: 49 | <<: *defaults 50 | database: foo_production 51 | collection: inbox 52 | 53 | ### Sidekiq/Redis 54 | 55 | Example: 56 | 57 | production: 58 | redis_url: redis://localhost:6379 59 | namespace: resque:gitlab 60 | queue: email_receiver 61 | worker: EmailReceiverWorker 62 | 63 | 64 | Running 65 | ------- 66 | Check -h for help, port/unix socket path and config file are required. 67 | 68 | 69 | Bugs and missing features 70 | ------------------------- 71 | 72 | * When using UNIX socket the permissions/ownership are not changed. Use -u and -g when running 73 | as daemon or change the permissions/ownership manually. 74 | * ReceiveD wasn't really tested for compliance with RFC2033 75 | * It doesn't implement [RFC2034][5] (ENHANCEDSTATUSCODES), because Postfix doesn't seem to care 76 | * It doesn't perform any validation of the provided input, e.g. LHLO, MAIL FROM, RCPT TO 77 | 78 | [1]: http://tools.ietf.org/html/rfc2033 79 | [2]: http://rubyeventmachine.com/ 80 | [3]: http://www.mongodb.org/ 81 | [4]: http://www.postfix.org/ 82 | [5]: http://tools.ietf.org/html/rfc2034 83 | [6]: https://sidekiq.org/ 84 | 85 | Copyright (c) 2011 Roman Shterenzon, released under the MIT license 86 | -------------------------------------------------------------------------------- /lib/received/server.rb: -------------------------------------------------------------------------------- 1 | #require 'active_support/core_ext/string/inflections' 2 | require 'yaml' 3 | require 'eventmachine' 4 | require 'received/connection' 5 | 6 | module Received 7 | class Server 8 | attr_reader :options 9 | 10 | def initialize(options) 11 | @options = options 12 | Received.logger ||= (options[:logger] || Logger.new(STDERR)) 13 | @connections = [] 14 | # For how long the server will wait for connections to finish 15 | @grace_period = options[:grace_period] || 10 16 | create_backend 17 | end 18 | 19 | def serve! 20 | EventMachine.run { start } 21 | end 22 | 23 | def start 24 | unless options[:unix_socket] or options[:port] 25 | raise "No port or UNIX socket path were provided" 26 | end 27 | set_title 28 | if host = options[:unix_socket] 29 | port = nil 30 | else 31 | host = options[:host] || '127.0.0.1' 32 | port = options[:port] 33 | end 34 | logger.info "Starting server on #{host}#{port ? ":" + port.to_s : ''}" 35 | @signature = EventMachine.start_server(host, port, Received::Connection, self, @backend) do |conn| 36 | add_connection(conn) 37 | end 38 | end 39 | 40 | def stop 41 | return if stopping? 42 | logger.info "Stopping server" 43 | EventMachine.stop_server(@signature) 44 | @stopped_at = Time.now 45 | unless wait_for_connections_and_stop 46 | # Still some connections running, schedule a check later 47 | EventMachine.add_periodic_timer(1) { wait_for_connections_and_stop } 48 | end 49 | end 50 | 51 | # Checks whether the server is in stopping mode 52 | def stopping? 53 | !!@stopped_at 54 | end 55 | 56 | # Checks if the server is processing any connections 57 | def idle? 58 | @connections.empty? 59 | end 60 | 61 | def remove_connection(conn) 62 | @connections.delete(conn) 63 | set_title 64 | end 65 | 66 | def logger 67 | Received.logger 68 | end 69 | 70 | private 71 | 72 | # Sets the process title as seen in ps 73 | def set_title 74 | $0 = "received (#{@connections.size} connections)" 75 | end 76 | 77 | # Whether grace period is over 78 | def grace_ended? 79 | Time.now - @stopped_at > @grace_period 80 | end 81 | 82 | def wait_for_connections_and_stop 83 | if idle? or grace_ended? 84 | EventMachine.stop 85 | true 86 | else 87 | logger.info "Waiting for #{@connections.size} connection(s) to finish..." 88 | false 89 | end 90 | end 91 | 92 | def add_connection(conn) 93 | @connections << conn 94 | set_title 95 | end 96 | 97 | def create_backend 98 | backend = options[:backend].to_s 99 | require 'received/backend/' + backend 100 | #klass = ('Received::Backend::' + backend.camelize).constantize 101 | klass = eval('Received::Backend::' + backend.capitalize) 102 | env = ENV['RAILS_ENV'] || 'production' 103 | config = YAML.load(File.read(options[:config]))[env] 104 | @backend = klass.new(config) 105 | end 106 | 107 | end 108 | end -------------------------------------------------------------------------------- /lib/received/lmtp.rb: -------------------------------------------------------------------------------- 1 | module Received 2 | # RFC2033 3 | class LMTP 4 | 5 | def initialize(conn) 6 | @conn = conn 7 | @state = :start 8 | @buf = '' 9 | end 10 | 11 | def on_data(data) 12 | @buf += data 13 | while line = @buf.slice!(/.*\r\n/) 14 | line.chomp! unless @state == :data 15 | event(line) 16 | end 17 | end 18 | 19 | def start! 20 | event(nil) 21 | end 22 | 23 | private 24 | def reset! 25 | @buf = '' 26 | @from = nil 27 | @rcpt = [] 28 | @body = [] 29 | end 30 | 31 | def event(ev) 32 | Received.logger.debug {"state was: #{@state.inspect}"} 33 | @state = case @state 34 | when :start 35 | reset! 36 | banner 37 | :banner_sent 38 | when :banner_sent 39 | if ev.start_with?('LHLO') 40 | lhlo_response 41 | extensions 42 | :lhlo_received 43 | else 44 | error 45 | end 46 | when :lhlo_received 47 | if ev =~ /^MAIL FROM:]*)/ 48 | @from = $1 49 | ok 50 | :mail_from_received 51 | else 52 | error 53 | end 54 | when :mail_from_received 55 | if ev =~ /^RCPT TO:]*)/ 56 | @rcpt << $1 57 | ok 58 | :rcpt_to_received 59 | else 60 | error 61 | end 62 | when :rcpt_to_received 63 | if ev =~ /^RCPT TO:]*)/ 64 | @rcpt << $1 65 | ok 66 | elsif ev == "DATA" 67 | start_mail_input 68 | :data 69 | else 70 | error 71 | end 72 | when :data 73 | if ev == ".\r\n" 74 | mail = {:from => @from, :rcpt => @rcpt, :body => @body.join.force_encoding('BINARY')} 75 | if @conn.mail_received(mail) 76 | @rcpt.size.times {ok} 77 | else 78 | @rcpt.size.times {error_in_processing} 79 | end 80 | :data_received 81 | else 82 | @body << ev 83 | :data 84 | end 85 | when :data_received 86 | if ev == "QUIT" 87 | closing_connection 88 | :start 89 | else 90 | error 91 | end 92 | else 93 | raise "Where am I? (#{@state.inspect})" 94 | end || @state 95 | Received.logger.debug {"state now: #{@state.inspect}"} 96 | end 97 | 98 | def banner 99 | emit "220 localhost LMTP server ready" 100 | end 101 | 102 | def lhlo_response 103 | emit "250-localhost" 104 | end 105 | 106 | def start_mail_input 107 | emit "354 End data with ." 108 | end 109 | 110 | def closing_connection 111 | emit "221 Bye" 112 | @conn.close_connection_after_writing 113 | end 114 | 115 | # FIXME: RFC2033 requires ENHANCEDSTATUSCODES, 116 | # but it's not used in Postfix 117 | def extensions 118 | emit "250-8BITMIME\r\n250 PIPELINING" 119 | end 120 | 121 | def ok 122 | emit "250 OK" 123 | end 124 | 125 | def error 126 | emit "500 command unrecognized" 127 | end 128 | 129 | def error_in_processing 130 | emit "451 Requested action aborted: local error in processing" 131 | end 132 | 133 | def emit(str) 134 | @conn.send_data "#{str}\r\n" 135 | # return nil, so there won't be implicit state transition 136 | nil 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/lmtp_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'logger' 3 | 4 | describe Received::LMTP do 5 | let(:conn) { mock(:conn) } 6 | let(:proto) { Received::LMTP.new(conn) } 7 | 8 | before :all do 9 | Received.logger = Logger.new(STDERR) 10 | end 11 | 12 | before do 13 | conn.should_receive(:send_data).with("220 localhost LMTP server ready\r\n") 14 | #conn.logger.debug "*** Starting test ***" 15 | proto.start! 16 | end 17 | 18 | describe "Full flow" do 19 | let(:body) { "Subject: spec\r\nspec\r\n" } 20 | 21 | def begin_flow! 22 | ["LHLO", "MAIL FROM:", "RCPT TO:", 23 | "RCPT TO:", "DATA", "#{body}.", "QUIT"].each do |line| 24 | # conn.logger.debug "client: #{line}" 25 | proto.on_data(line + "\r\n") 26 | end 27 | end 28 | 29 | def common_expectations! 30 | conn.should_receive(:send_data).with("250-localhost\r\n") 31 | conn.should_receive(:send_data).with("250-8BITMIME\r\n250 PIPELINING\r\n") 32 | conn.should_receive(:send_data).with("250 OK\r\n").exactly(3).times 33 | conn.should_receive(:send_data).with("354 End data with .\r\n") 34 | end 35 | 36 | it "receives mail" do 37 | common_expectations! 38 | conn.should_receive(:send_data).with("250 OK\r\n").exactly(2).times 39 | conn.should_receive(:send_data).with("221 Bye\r\n") 40 | conn.should_receive(:mail_received).with({ 41 | :from => 'spec1@example.com', 42 | :rcpt => ['spec2@example.com', 'spec3@example.com'], 43 | :body => body 44 | }).and_return(true) 45 | conn.should_receive(:close_connection_after_writing) 46 | 47 | begin_flow! 48 | end 49 | 50 | 51 | it "returns error when it cannot save email" do 52 | common_expectations! 53 | conn.should_receive(:mail_received).once.and_return(false) 54 | conn.should_receive(:send_data).with(/451/).exactly(2).times 55 | conn.should_receive(:send_data).with("221 Bye\r\n") 56 | conn.should_receive(:close_connection_after_writing) 57 | 58 | begin_flow! 59 | end 60 | end 61 | 62 | it "parses multiline" do 63 | conn.should_receive(:send_data).with("250-localhost\r\n") 64 | conn.should_receive(:send_data).with("250-8BITMIME\r\n250 PIPELINING\r\n") 65 | conn.should_receive(:send_data).with("250 OK\r\n") 66 | proto.on_data("LHLO\r\nMAIL FROM:\r\n") 67 | end 68 | 69 | it "buffers commands up to CR/LF" do 70 | conn.should_receive(:send_data).with("250-localhost\r\n") 71 | conn.should_receive(:send_data).with("250-8BITMIME\r\n250 PIPELINING\r\n") 72 | conn.should_receive(:send_data).with("250 OK\r\n") 73 | proto.on_data("LHLO\r\nMAIL FROM") 74 | proto.on_data(":\r\n") 75 | end 76 | 77 | it "passes CR/LF through" do 78 | body = "Subject: test\r\n\r\nTest\r\n" 79 | conn.stub!(:send_data) 80 | proto.on_data("LHLO\r\nMAIL FROM:\r\nRCPT TO:\r\nDATA\r\n") 81 | proto.on_data(body) 82 | conn.should_receive(:mail_received) do |r| 83 | r[:body].should == body 84 | end 85 | proto.on_data(".\r\n") 86 | end 87 | 88 | it "allows empty FROM" do 89 | conn.stub(:send_data) 90 | conn.stub(:close_connection_after_writing) 91 | conn.should_receive(:mail_received) do |mail| 92 | mail[:from].should be_empty 93 | end 94 | 95 | ["LHLO", "MAIL FROM:<>", "RCPT TO:", 96 | "RCPT TO:", "DATA", "testing\r\n.", "QUIT"].each do |line| 97 | proto.on_data(line + "\r\n") 98 | end 99 | 100 | end 101 | end 102 | --------------------------------------------------------------------------------