مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2025-11-30 21:32:30 +00:00
test: add initial tests for Postal::SMTPServer::Client
هذا الالتزام موجود في:
@@ -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]
|
||||
|
||||
3
Gemfile
3
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
|
||||
|
||||
52
Gemfile.lock
52
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
9
spec/factories/credential_factory.rb
Normal file
9
spec/factories/credential_factory.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :credential do
|
||||
server
|
||||
name { "Example Credential" }
|
||||
type { "API" }
|
||||
end
|
||||
end
|
||||
@@ -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 }
|
||||
|
||||
11
spec/factories/http_endpoint_factory.rb
Normal file
11
spec/factories/http_endpoint_factory.rb
Normal file
@@ -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
|
||||
14
spec/factories/route_factory.rb
Normal file
14
spec/factories/route_factory.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
122
spec/lib/postal/smtp_server/client/auth_spec.rb
Normal file
122
spec/lib/postal/smtp_server/client/auth_spec.rb
Normal file
@@ -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
|
||||
86
spec/lib/postal/smtp_server/client/data_spec.rb
Normal file
86
spec/lib/postal/smtp_server/client/data_spec.rb
Normal file
@@ -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
|
||||
212
spec/lib/postal/smtp_server/client/finished_spec.rb
Normal file
212
spec/lib/postal/smtp_server/client/finished_spec.rb
Normal file
@@ -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
|
||||
38
spec/lib/postal/smtp_server/client/helo_spec.rb
Normal file
38
spec/lib/postal/smtp_server/client/helo_spec.rb
Normal file
@@ -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
|
||||
35
spec/lib/postal/smtp_server/client/mail_from_spec.rb
Normal file
35
spec/lib/postal/smtp_server/client/mail_from_spec.rb
Normal file
@@ -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
|
||||
172
spec/lib/postal/smtp_server/client/rcpt_to_spec.rb
Normal file
172
spec/lib/postal/smtp_server/client/rcpt_to_spec.rb
Normal file
@@ -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
|
||||
14
spec/lib/postal/smtp_server/client_spec.rb
Normal file
14
spec/lib/postal/smtp_server/client_spec.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم