diff --git a/.rubocop.yml b/.rubocop.yml index 3c7e58f..25bb950 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,6 +10,7 @@ AllCops: # Always use double quotes Style/StringLiterals: EnforcedStyle: double_quotes + AutoCorrect: true # We prefer arrays of symbols to look like an array of symbols. # For example: [:one, :two, :three] as opposed to %i[one two three] diff --git a/Gemfile b/Gemfile index f5e76cf..31b5bef 100644 --- a/Gemfile +++ b/Gemfile @@ -48,9 +48,10 @@ end group :development do gem "annotate" gem "database_cleaner", require: false - gem "factory_bot_rails", "~> 4.0", require: false + gem "factory_bot_rails", require: false gem "rspec", require: false gem "rspec-rails", require: false gem "rubocop" gem "rubocop-rails" + gem "timecop" end diff --git a/Gemfile.lock b/Gemfile.lock index 56d7833..ada7fd9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -89,7 +89,7 @@ GEM coffee-script-source execjs coffee-script-source (1.12.2) - concurrent-ruby (1.2.2) + concurrent-ruby (1.2.3) crass (1.0.6) database_cleaner (2.0.2) database_cleaner-active_record (>= 2, < 3) @@ -104,15 +104,17 @@ GEM dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) - dynamic_form (1.1.4) + dynamic_form (1.3.1) + actionview (> 5.2.0) + activemodel (> 5.2.0) encrypto_signo (1.0.0) erubi (1.12.0) execjs (2.7.0) - factory_bot (4.11.1) - activesupport (>= 3.0.0) - factory_bot_rails (4.11.1) - factory_bot (~> 4.11.1) - railties (>= 3.0.0) + factory_bot (6.4.6) + activesupport (>= 5.0.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) + railties (>= 5.0.0) ffi (1.15.5) foreman (0.87.2) gelf (3.1.0) @@ -125,7 +127,7 @@ GEM tilt hashie (5.0.0) highline (2.1.0) - i18n (1.12.0) + i18n (1.14.1) concurrent-ruby (~> 1.0) jquery-rails (4.5.1) rails-dom-testing (>= 1, < 3) @@ -145,9 +147,9 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - loofah (2.19.1) + loofah (2.22.0) crass (~> 1.0.2) - nokogiri (>= 1.5.9) + nokogiri (>= 1.12.0) mail (2.8.1) mini_mime (>= 0.1.1) net-imap @@ -156,8 +158,7 @@ GEM marcel (1.0.2) method_source (1.0.0) mini_mime (1.1.2) - mini_portile2 (2.8.5) - minitest (5.18.0) + minitest (5.22.2) moonrope (2.0.2) deep_merge (~> 1.0) json @@ -177,9 +178,6 @@ GEM activerecord (>= 4.0.0) activesupport (>= 4.0.0) nio4r (2.7.0) - nokogiri (1.16.2) - mini_portile2 (~> 2.8.2) - racc (~> 1.4) nokogiri (1.16.2-arm64-darwin) racc (~> 1.4) nokogiri (1.16.2-x86_64-linux) @@ -190,7 +188,7 @@ GEM puma (6.4.2) nio4r (~> 2.0) racc (1.7.3) - rack (2.2.6.4) + rack (2.2.8) rack-test (2.1.0) rack (>= 1.3) rails (6.1.7.6) @@ -208,11 +206,13 @@ GEM bundler (>= 1.15.0) railties (= 6.1.7.6) sprockets-rails (>= 2.0.0) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) railties (6.1.7.6) actionpack (= 6.1.7.6) activesupport (= 6.1.7.6) @@ -220,7 +220,7 @@ GEM rake (>= 12.2) thor (~> 1.0) rainbow (3.1.1) - rake (13.0.6) + rake (13.1.0) rbtree (0.4.6) regexp_parser (2.7.0) resolv (0.2.2) @@ -292,8 +292,9 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) temple (0.10.0) - thor (1.2.1) + thor (1.3.0) tilt (2.1.0) + timecop (0.9.8) timeout (0.3.2) turbolinks (5.2.1) turbolinks-source (~> 5.2) @@ -306,11 +307,11 @@ GEM websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.6.7) + zeitwerk (2.6.13) PLATFORMS arm64-darwin-22 - ruby + arm64-darwin-23 x86_64-linux DEPENDENCIES @@ -330,7 +331,7 @@ DEPENDENCIES dynamic_form encrypto_signo execjs (~> 2.7, < 2.8) - factory_bot_rails (~> 4.0) + factory_bot_rails foreman gelf haml @@ -356,6 +357,7 @@ DEPENDENCIES secure_headers sentry-rails sentry-ruby + timecop turbolinks (~> 5) uglifier (>= 1.3.0) diff --git a/lib/postal/message_db/delivery.rb b/lib/postal/message_db/delivery.rb index 177b7f9..dfe90cd 100644 --- a/lib/postal/message_db/delivery.rb +++ b/lib/postal/message_db/delivery.rb @@ -25,7 +25,7 @@ module Postal @attributes[name.to_s] end - def respond_to_missing?(name) + def respond_to_missing?(name, include_private = false) @attributes.key?(name.to_s) end diff --git a/lib/postal/message_db/message.rb b/lib/postal/message_db/message.rb index 7222640..c01ba3e 100644 --- a/lib/postal/message_db/message.rb +++ b/lib/postal/message_db/message.rb @@ -185,7 +185,7 @@ module Postal end end - def respond_to_missing?(name) + def respond_to_missing?(name, include_private = false) name = name.to_s.sub(/=\z/, "") @attributes.key?(name.to_s) end diff --git a/lib/postal/smtp_server/client.rb b/lib/postal/smtp_server/client.rb index 971995a..790e7dc 100644 --- a/lib/postal/smtp_server/client.rb +++ b/lib/postal/smtp_server/client.rb @@ -11,6 +11,12 @@ module Postal LOG_REDACTION_STRING = "[redacted]" attr_reader :logging_enabled + attr_reader :credential + attr_reader :ip_address + attr_reader :recipients + attr_reader :headers + attr_reader :state + attr_reader :helo_name def initialize(ip_address) @logging_enabled = true @@ -55,19 +61,6 @@ module Postal end end - def sanitize_input_for_log(data) - if @password_expected_next - @password_expected_next = false - if data =~ /\A[a-z0-9]{3,}=*\z/i - return LOG_REDACTION_STRING - end - end - - data = data.dup - data.gsub!(/(.*AUTH \w+) (.*)\z/i) { "#{::Regexp.last_match(1)} #{LOG_REDACTION_STRING}" } - data - end - def finished? @finished || false end @@ -137,7 +130,11 @@ module Postal @helo_name = data.strip.split(" ", 2)[1] transaction_reset @state = :welcomed - ["250-My capabilities are", Postal.config.smtp_server.tls_enabled? && !@tls ? "250-STARTTLS" : nil, "250 AUTH CRAM-MD5 PLAIN LOGIN"] + [ + "250-My capabilities are", + Postal.config.smtp_server.tls_enabled? && !@tls ? "250-STARTTLS" : nil, + "250 AUTH CRAM-MD5 PLAIN LOGIN" + ].compact end def helo(data) @@ -194,7 +191,7 @@ module Postal "334 UGFzc3dvcmQ6" # "Password:" end - data = data.gsub!(/AUTH LOGIN ?/i, "") + data = data.gsub(/AUTH LOGIN ?/i, "") if data.strip == "" @proc = username_handler "334 VXNlcm5hbWU6" # "Username:" @@ -226,6 +223,7 @@ module Postal log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" next "535 Denied" end + grant = nil server.credentials.where(type: "SMTP").each do |credential| correct_response = OpenSSL::HMAC.hexdigest(CRAM_MD5_DIGEST, credential.key, challenge) @@ -236,10 +234,12 @@ module Postal grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}" break end + if grant.nil? log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" next "535 Denied" end + grant end @@ -458,6 +458,7 @@ module Postal msg.mail_from = @mail_from msg.raw_message = @data msg.received_with_ssl = @tls + msg.bounce = 1 end else # There's no return path route, we just need to insert the mesage @@ -489,6 +490,19 @@ module Postal states.include?(@state) end + def sanitize_input_for_log(data) + if @password_expected_next + @password_expected_next = false + if data =~ /\A[a-z0-9]{3,}=*\z/i + return LOG_REDACTION_STRING + end + end + + data = data.dup + data.gsub!(/(.*AUTH \w+) (.*)\z/i) { "#{::Regexp.last_match(1)} #{LOG_REDACTION_STRING}" } + data + end + end end end diff --git a/spec/factories/credential_factory.rb b/spec/factories/credential_factory.rb new file mode 100644 index 0000000..5be9e3d --- /dev/null +++ b/spec/factories/credential_factory.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :credential do + server + name { "Example Credential" } + type { "API" } + end +end diff --git a/spec/factories/domain_factory.rb b/spec/factories/domain_factory.rb index e128497..fe9ca66 100644 --- a/spec/factories/domain_factory.rb +++ b/spec/factories/domain_factory.rb @@ -38,7 +38,7 @@ FactoryBot.define do factory :domain do - association :owner, factory: :user + association :owner, factory: :organization sequence(:name) { |n| "example#{n}.com" } verification_method { "DNS" } verified_at { Time.now } diff --git a/spec/factories/http_endpoint_factory.rb b/spec/factories/http_endpoint_factory.rb new file mode 100644 index 0000000..5fec9c8 --- /dev/null +++ b/spec/factories/http_endpoint_factory.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :http_endpoint do + server + name { "HTTP endpoint" } + url { "https://example.com/endpoint" } + encoding { "BodyAsJSON" } + format { "Hash" } + end +end diff --git a/spec/factories/route_factory.rb b/spec/factories/route_factory.rb new file mode 100644 index 0000000..045c487 --- /dev/null +++ b/spec/factories/route_factory.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :route do + name { "test" } + mode { "Accept" } + spam_mode { "Mark" } + + before(:create) do |route| + route.server ||= create(:server) + route.domain ||= create(:domain, owner: route.server) + end + end +end diff --git a/spec/factories/server_factory.rb b/spec/factories/server_factory.rb index bd8c74e..3866001 100644 --- a/spec/factories/server_factory.rb +++ b/spec/factories/server_factory.rb @@ -48,5 +48,9 @@ FactoryBot.define do mode { "Live" } provision_database { false } sequence(:permalink) { |n| "server#{n}" } + + trait :suspended do + suspended_at { Time.current } + end end end diff --git a/spec/lib/postal/smtp_server/client/auth_spec.rb b/spec/lib/postal/smtp_server/client/auth_spec.rb new file mode 100644 index 0000000..bdb271d --- /dev/null +++ b/spec/lib/postal/smtp_server/client/auth_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Postal + module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + before do + client.handle("HELO test.example.com") + end + + describe "AUTH PLAIN" do + context "when no credentials are provided on the initial data" do + it "returns a 334" do + expect(client.handle("AUTH PLAIN")).to eq("334") + end + + it "accepts the username and password from the next input" do + client.handle("AUTH PLAIN") + credential = create(:credential, type: "SMTP") + expect(client.handle(credential.to_smtp_plain)).to match(/235 Granted for/) + end + end + + context "when valid credentials are provided on one line" do + it "authenticates and returns a response" do + credential = create(:credential, type: "SMTP") + expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for/) + expect(client.credential).to eq credential + end + end + + context "when invalid credentials are provided" do + it "returns an error and resets the state" do + base64 = Base64.encode64("user\0pass") + expect(client.handle("AUTH PLAIN #{base64}")).to eq("535 Invalid credential") + expect(client.state).to eq :welcomed + end + end + + context "when username or password is missing" do + it "returns an error and resets the state" do + base64 = Base64.encode64("pass") + expect(client.handle("AUTH PLAIN #{base64}")).to eq("535 Authenticated failed - protocol error") + expect(client.state).to eq :welcomed + end + end + end + + describe "AUTH LOGIN" do + context "when no username is provided on the first line" do + it "requests the username" do + expect(client.handle("AUTH LOGIN")).to eq("334 VXNlcm5hbWU6") + end + end + + context "when a username is provided on the first line" do + it "requests a password" do + username = Base64.encode64("xx") + expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6") + end + + it "authenticates and returns a response" do + credential = create(:credential, type: "SMTP") + username = Base64.encode64("xx") + password = Base64.encode64(credential.key) + expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6") + expect(client.handle(password)).to match(/235 Granted for/) + expect(client.credential).to eq credential + end + end + + context "when invalid credentials are provided" do + it "returns an error and resets the state" do + username = Base64.encode64("xx") + password = Base64.encode64("xx") + expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6") + expect(client.handle(password)).to eq("535 Invalid credential") + expect(client.state).to eq :welcomed + end + end + end + + describe "AUTH CRAM-MD5" do + context "when valid credentials are provided" do + it "authenticates and returns a response" do + credential = create(:credential, type: "SMTP") + result = client.handle("AUTH CRAM-MD5") + expect(result).to match(/\A334 [A-Za-z0-9=]+\z/) + challenge = Base64.decode64(result.split[1]) + password = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("md5"), credential.key, challenge) + base64 = Base64.encode64("#{credential.server.organization.permalink}/#{credential.server.permalink} #{password}") + expect(client.handle(base64)).to match(/235 Granted for/) + expect(client.credential).to eq credential + end + end + + context "when no org/server matches the provided username" do + it "returns an error" do + client.handle("AUTH CRAM-MD5") + base64 = Base64.encode64("org/server password") + expect(client.handle(base64)).to eq "535 Denied" + end + end + + context "when invalid credentials are provided" do + it "returns an error and resets the state" do + server = create(:server) + base64 = Base64.encode64("#{server.organization.permalink}/#{server.permalink} invalid-password") + client.handle("AUTH CRAM-MD5") + expect(client.handle(base64)).to eq("535 Denied") + end + end + end + end + + end +end diff --git a/spec/lib/postal/smtp_server/client/data_spec.rb b/spec/lib/postal/smtp_server/client/data_spec.rb new file mode 100644 index 0000000..d79fbb2 --- /dev/null +++ b/spec/lib/postal/smtp_server/client/data_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Postal + module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + describe "DATA" do + it "returns an error if no helo" do + expect(client.handle("DATA")).to eq "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data" + end + + it "returns an error if no mail from" do + client.handle("HELO test.example.com") + expect(client.handle("DATA")).to eq "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data" + end + + it "returns an error if no rcpt to" do + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@example.com") + expect(client.handle("DATA")).to eq "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data" + end + + it "returns go ahead" do + route = create(:route) + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@test.com") + client.handle("RCPT TO: #{route.name}@#{route.domain.name}") + expect(client.handle("DATA")).to eq "354 Go ahead" + end + + it "adds a received header for itself" do + route = create(:route) + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@test.com") + client.handle("RCPT TO: #{route.name}@#{route.domain.name}") + Timecop.freeze do + client.handle("DATA") + expect(client.headers["received"]).to include "from test.example.com (1.2.3.4 [1.2.3.4]) by postal.example.com with SMTP; #{Time.now.utc.rfc2822}" + end + end + + describe "subsequent commands" do + let(:route) { create(:route) } + before do + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@test.com") + client.handle("RCPT TO: #{route.name}@#{route.domain.name}") + client.handle("DATA") + end + + it "logs headers" do + client.handle("Subject: Test") + client.handle("From: test@test.com") + client.handle("To: test1@example.com") + client.handle("To: test2@example.com") + client.handle("X-Something: abcdef1234") + expect(client.headers["subject"]).to eq ["Test"] + expect(client.headers["from"]).to eq ["test@test.com"] + expect(client.headers["to"]).to eq ["test1@example.com", "test2@example.com"] + expect(client.headers["x-something"]).to eq ["abcdef1234"] + end + + it "logs content" do + client.handle("Subject: Test") + client.handle("") + client.handle("This is some content for the message.") + client.handle("It will keep going.") + expect(client.instance_variable_get("@data")).to eq <<~DATA + Received: from test.example.com (1.2.3.4 [1.2.3.4]) by #{Postal.config.dns.smtp_server_hostname} with SMTP; #{Time.now.utc.rfc2822}\r + Subject: Test\r + \r + This is some content for the message.\r + It will keep going.\r + DATA + end + end + end + end + + end +end diff --git a/spec/lib/postal/smtp_server/client/finished_spec.rb b/spec/lib/postal/smtp_server/client/finished_spec.rb new file mode 100644 index 0000000..6438419 --- /dev/null +++ b/spec/lib/postal/smtp_server/client/finished_spec.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Postal + module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + let(:server) { GLOBAL_SERVER } # We'll use the global server instance for this + subject(:client) { described_class.new(ip_address) } + + let(:credential) { create(:credential, server: server, type: "SMTP") } + let(:auth_plain) { credential&.to_smtp_plain } + let(:mail_from) { "test@example.com" } + let(:rcpt_to) { "test@example.com" } + + before do + client.handle("HELO test.example.com") + client.handle("AUTH PLAIN #{auth_plain}") if auth_plain + client.handle("MAIL FROM: #{mail_from}") + client.handle("RCPT TO: #{rcpt_to}") + end + + after do + server.message_db.provisioner.clean + end + + describe "when finished sending data" do + context "when the data is larger than the maximum message size" do + it "returns an error and resets the state" do + allow(Postal.config.smtp_server).to receive(:max_message_size).and_return(1) + client.handle("DATA") + client.handle("a" * 1024 * 1024 * 10) + expect(client.handle(".")).to eq "552 Message too large (maximum size 1MB)" + end + end + + context "when a loop is detected" do + it "returns an error and resets the state" do + client.handle("DATA") + client.handle("Received: from example1.com by #{Postal.config.dns.smtp_server_hostname}") + client.handle("Received: from example2.com by #{Postal.config.dns.smtp_server_hostname}") + client.handle("Received: from example1.com by #{Postal.config.dns.smtp_server_hostname}") + client.handle("Received: from example2.com by #{Postal.config.dns.smtp_server_hostname}") + client.handle("Subject: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "550 Loop detected" + end + end + + context "when the email content is not suitable for the credential" do + it "returns an error and resets the state" do + client.handle("DATA") + client.handle("Subject: Test") + client.handle("From: invalid@krystal.uk") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "530 From/Sender name is not valid" + end + end + + context "when sending an outgoing email" do + let(:domain) { create(:domain, owner: server) } + let(:mail_from) { "test@#{domain.name}" } + let(:auth_plain) { credential.to_smtp_plain } + + it "stores the message and resets the state" do + client.handle("DATA") + client.handle("Subject: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "250 OK" + queued_message = QueuedMessage.first + expect(queued_message).to have_attributes( + domain: "example.com", + server: server + ) + + expect(server.message(queued_message.message_id)).to have_attributes( + mail_from: mail_from, + rcpt_to: rcpt_to, + subject: "Test", + scope: "outgoing", + route_id: nil, + credential_id: credential.id, + raw_headers: kind_of(String), + raw_message: kind_of(String) + ) + end + end + + context "when sending a bounce message" do + let(:credential) { nil } + let(:rcpt_to) { "#{server.token}@#{Postal.config.dns.return_path}" } + + context "when there is a return path route" do + let(:domain) { create(:domain, owner: server) } + + before do + endpoint = create(:http_endpoint, server: server) + create(:route, domain: domain, server: server, name: "__returnpath__", mode: "Endpoint", endpoint: endpoint) + end + + it "stores the message for the return path route and resets the state" do + client.handle("DATA") + client.handle("Subject: Bounce: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "250 OK" + + queued_message = QueuedMessage.first + expect(queued_message).to have_attributes( + domain: Postal.config.dns.return_path, + server: server + ) + + expect(server.message(queued_message.message_id)).to have_attributes( + mail_from: mail_from, + rcpt_to: rcpt_to, + subject: "Bounce: Test", + scope: "incoming", + route_id: server.routes.first.id, + domain_id: domain.id, + credential_id: nil, + raw_headers: kind_of(String), + raw_message: kind_of(String), + bounce: true + ) + end + end + + context "when there is no return path route" do + it "stores the message normally and resets the state" do + client.handle("DATA") + client.handle("Subject: Bounce: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "250 OK" + + queued_message = QueuedMessage.first + expect(queued_message).to have_attributes( + domain: Postal.config.dns.return_path, + server: server + ) + + expect(server.message(queued_message.message_id)).to have_attributes( + mail_from: mail_from, + rcpt_to: rcpt_to, + subject: "Bounce: Test", + scope: "incoming", + route_id: nil, + domain_id: nil, + credential_id: nil, + raw_headers: kind_of(String), + raw_message: kind_of(String), + bounce: true + ) + end + end + end + + context "when receiving an incoming email" do + let(:domain) { create(:domain, owner: server) } + let(:route) { create(:route, server: server, domain: domain) } + + let(:credential) { nil } + let(:rcpt_to) { "#{route.name}@#{domain.name}" } + + it "stores the message and resets the state" do + client.handle("DATA") + client.handle("Subject: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "250 OK" + + queued_message = QueuedMessage.first + expect(queued_message).to have_attributes( + domain: domain.name, + server: server + ) + + expect(server.message(queued_message.message_id)).to have_attributes( + mail_from: mail_from, + rcpt_to: rcpt_to, + subject: "Test", + scope: "incoming", + route_id: route.id, + domain_id: domain.id, + credential_id: nil, + raw_headers: kind_of(String), + raw_message: kind_of(String) + ) + end + end + end + end + + end +end diff --git a/spec/lib/postal/smtp_server/client/helo_spec.rb b/spec/lib/postal/smtp_server/client/helo_spec.rb new file mode 100644 index 0000000..85d513b --- /dev/null +++ b/spec/lib/postal/smtp_server/client/helo_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Postal + module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + describe "HELO" do + it "returns the hostname" do + expect(client.state).to eq :welcome + expect(client.handle("HELO: test.example.com")).to eq "250 #{Postal.config.dns.smtp_server_hostname}" + expect(client.state).to eq :welcomed + end + end + + describe "EHLO" do + it "returns the capabilities" do + expect(client.handle("EHLO test.example.com")).to eq ["250-My capabilities are", + "250 AUTH CRAM-MD5 PLAIN LOGIN"] + end + + context "when TLS is enabled" do + it "returns capabilities include starttls" do + allow(Postal.config.smtp_server).to receive(:tls_enabled?).and_return(true) + expect(client.handle("EHLO test.example.com")).to eq ["250-My capabilities are", + "250-STARTTLS", + "250 AUTH CRAM-MD5 PLAIN LOGIN"] + end + end + end + end + + end +end diff --git a/spec/lib/postal/smtp_server/client/mail_from_spec.rb b/spec/lib/postal/smtp_server/client/mail_from_spec.rb new file mode 100644 index 0000000..b62de45 --- /dev/null +++ b/spec/lib/postal/smtp_server/client/mail_from_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Postal + module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + describe "MAIL FROM" do + it "returns an error if no HELO is provided" do + expect(client.handle("MAIL FROM: test@example.com")).to eq "503 EHLO/HELO first please" + expect(client.state).to eq :welcome + end + + it "resets the transaction when called" do + expect(client).to receive(:transaction_reset).and_call_original.at_least(3).times + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@example.com") + client.handle("MAIL FROM: test2@example.com") + end + + it "sets the mail from address" do + client.handle("HELO test.example.com") + expect(client.handle("MAIL FROM: test@example.com")).to eq "250 OK" + expect(client.state).to eq :mail_from_received + expect(client.instance_variable_get("@mail_from")).to eq "test@example.com" + end + end + end + + end +end diff --git a/spec/lib/postal/smtp_server/client/rcpt_to_spec.rb b/spec/lib/postal/smtp_server/client/rcpt_to_spec.rb new file mode 100644 index 0000000..ee35abb --- /dev/null +++ b/spec/lib/postal/smtp_server/client/rcpt_to_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Postal + module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + describe "RCPT TO" do + let(:helo) { "test.example.com" } + let(:mail_from) { "test@example.com" } + + before do + client.handle("HELO #{helo}") + client.handle("MAIL FROM: #{mail_from}") if mail_from + end + + context "when MAIL FROM has not been sent" do + let(:mail_from) { nil } + + it "returns an error if RCPT TO is sent before MAIL FROM" do + expect(client.handle("RCPT TO: no-route-here@internal.com")).to eq "503 EHLO/HELO and MAIL FROM first please" + expect(client.state).to eq :welcomed + end + end + + it "returns an error if RCPT TO is not valid" do + expect(client.handle("RCPT TO: blah")).to eq "501 Invalid RCPT TO" + end + + it "returns an error if RCPT TO is empty" do + expect(client.handle("RCPT TO: ")).to eq "501 RCPT TO should not be empty" + end + + context "when the RCPT TO address is the system return path host" do + it "returns an error if the server does not exist" do + expect(client.handle("RCPT TO: nothing@#{Postal.config.dns.return_path}")).to eq "550 Invalid server token" + end + + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + expect(client.handle("RCPT TO: #{server.token}@#{Postal.config.dns.return_path}")) + .to eq "535 Mail server has been suspended" + end + + it "adds a recipient if all OK" do + server = create(:server) + address = "#{server.token}@#{Postal.config.dns.return_path}" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:bounce, address, server]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when the RCPT TO address is on a host using the return path prefix" do + it "returns an error if the server does not exist" do + address = "nothing@#{Postal.config.dns.custom_return_path_prefix}.example.com" + expect(client.handle("RCPT TO: #{address}")).to eq "550 Invalid server token" + end + + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + address = "#{server.token}@#{Postal.config.dns.custom_return_path_prefix}.example.com" + expect(client.handle("RCPT TO: #{address}")).to eq "535 Mail server has been suspended" + end + + it "adds a recipient if all OK" do + server = create(:server) + address = "#{server.token}@#{Postal.config.dns.custom_return_path_prefix}.example.com" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:bounce, address, server]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when the RCPT TO address is within the route domain" do + it "returns an error if the route token is invalid" do + address = "nothing@#{Postal.config.dns.route_domain}" + expect(client.handle("RCPT TO: #{address}")).to eq "550 Invalid route token" + end + + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + route = create(:route, server: server) + address = "#{route.token}@#{Postal.config.dns.route_domain}" + expect(client.handle("RCPT TO: #{address}")).to eq "535 Mail server has been suspended" + end + + it "returns an error if the route is set to Reject mail" do + server = create(:server) + route = create(:route, server: server, mode: "Reject") + address = "#{route.token}@#{Postal.config.dns.route_domain}" + expect(client.handle("RCPT TO: #{address}")).to eq "550 Route does not accept incoming messages" + end + + it "adds a recipient if all OK" do + server = create(:server) + route = create(:route, server: server) + address = "#{route.token}+tag1@#{Postal.config.dns.route_domain}" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:route, "#{route.name}+tag1@#{route.domain.name}", server, { route: route }]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when authenticated and the RCPT TO address is provided" do + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + credential = create(:credential, server: server, type: "SMTP") + expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for /) + expect(client.handle("RCPT TO: outgoing@example.com")).to eq "535 Mail server has been suspended" + end + + it "adds a recipient if all OK" do + server = create(:server) + credential = create(:credential, server: server, type: "SMTP") + expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for /) + expect(client.handle("RCPT TO: outgoing@example.com")).to eq "250 OK" + expect(client.recipients).to eq [[:credential, "outgoing@example.com", server]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when not authenticated and the RCPT TO address is a route" do + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + route = create(:route, server: server) + address = "#{route.name}@#{route.domain.name}" + expect(client.handle("RCPT TO: #{address}")).to eq "535 Mail server has been suspended" + end + + it "returns an error if the route is set to Reject mail" do + server = create(:server) + route = create(:route, server: server, mode: "Reject") + address = "#{route.name}@#{route.domain.name}" + expect(client.handle("RCPT TO: #{address}")).to eq "550 Route does not accept incoming messages" + end + + it "adds a recipient if all OK" do + server = create(:server) + route = create(:route, server: server) + address = "#{route.name}@#{route.domain.name}" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:route, address, server, { route: route }]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when not authenticated and RCPT TO does not match a route" do + it "returns an error" do + expect(client.handle("RCPT TO: nothing@nothing.com")).to eq "530 Authentication required" + end + + context "when the connecting IP has an credential" do + it "adds a recipient" do + server = create(:server) + create(:credential, server: server, type: "SMTP-IP", key: "1.0.0.0/8") + address = "test@example.com" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:credential, address, server]] + expect(client.state).to eq :rcpt_to_received + end + end + end + end + end + + end +end diff --git a/spec/lib/postal/smtp_server/client_spec.rb b/spec/lib/postal/smtp_server/client_spec.rb new file mode 100644 index 0000000..0c9261b --- /dev/null +++ b/spec/lib/postal/smtp_server/client_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Postal + module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + end + + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 2e10886..ec9f85c 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -6,6 +6,7 @@ require File.expand_path("../config/environment", __dir__) require "rspec/rails" require "spec_helper" require "factory_bot" +require "timecop" require "database_cleaner" DatabaseCleaner.allow_remote_database_url = true