1
0
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2026-01-18 22:09:46 +00:00

Merge pull request #2801 from postalserver/smtp-tests

Tests for the SMTP Server
هذا الالتزام موجود في:
Adam Cooke
2024-02-13 15:55:57 +00:00
ملتزم من قبل GitHub
الأصل e83e2a5e33 c83601af69
التزام b8cb563553
25 ملفات معدلة مع 934 إضافات و122 حذوفات

عرض الملف

@@ -10,6 +10,7 @@ AllCops:
# Always use double quotes # Always use double quotes
Style/StringLiterals: Style/StringLiterals:
EnforcedStyle: double_quotes EnforcedStyle: double_quotes
AutoCorrect: true
# We prefer arrays of symbols to look like an array of symbols. # We prefer arrays of symbols to look like an array of symbols.
# For example: [:one, :two, :three] as opposed to %i[one two three] # For example: [:one, :two, :three] as opposed to %i[one two three]

عرض الملف

@@ -48,9 +48,10 @@ end
group :development do group :development do
gem "annotate" gem "annotate"
gem "database_cleaner", require: false 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", require: false
gem "rspec-rails", require: false gem "rspec-rails", require: false
gem "rubocop" gem "rubocop"
gem "rubocop-rails" gem "rubocop-rails"
gem "timecop"
end end

عرض الملف

@@ -89,7 +89,7 @@ GEM
coffee-script-source coffee-script-source
execjs execjs
coffee-script-source (1.12.2) coffee-script-source (1.12.2)
concurrent-ruby (1.2.2) concurrent-ruby (1.2.3)
crass (1.0.6) crass (1.0.6)
database_cleaner (2.0.2) database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3) database_cleaner-active_record (>= 2, < 3)
@@ -104,15 +104,17 @@ GEM
dotenv-rails (2.8.1) dotenv-rails (2.8.1)
dotenv (= 2.8.1) dotenv (= 2.8.1)
railties (>= 3.2) 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) encrypto_signo (1.0.0)
erubi (1.12.0) erubi (1.12.0)
execjs (2.7.0) execjs (2.7.0)
factory_bot (4.11.1) factory_bot (6.4.6)
activesupport (>= 3.0.0) activesupport (>= 5.0.0)
factory_bot_rails (4.11.1) factory_bot_rails (6.4.3)
factory_bot (~> 4.11.1) factory_bot (~> 6.4)
railties (>= 3.0.0) railties (>= 5.0.0)
ffi (1.15.5) ffi (1.15.5)
foreman (0.87.2) foreman (0.87.2)
gelf (3.1.0) gelf (3.1.0)
@@ -125,7 +127,7 @@ GEM
tilt tilt
hashie (5.0.0) hashie (5.0.0)
highline (2.1.0) highline (2.1.0)
i18n (1.12.0) i18n (1.14.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
jquery-rails (4.5.1) jquery-rails (4.5.1)
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
@@ -145,9 +147,9 @@ GEM
activerecord activerecord
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
loofah (2.19.1) loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
@@ -157,7 +159,7 @@ GEM
method_source (1.0.0) method_source (1.0.0)
mini_mime (1.1.2) mini_mime (1.1.2)
mini_portile2 (2.8.5) mini_portile2 (2.8.5)
minitest (5.18.0) minitest (5.22.2)
moonrope (2.0.2) moonrope (2.0.2)
deep_merge (~> 1.0) deep_merge (~> 1.0)
json json
@@ -190,7 +192,7 @@ GEM
puma (6.4.2) puma (6.4.2)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.7.3) racc (1.7.3)
rack (2.2.6.4) rack (2.2.8)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rails (6.1.7.6) rails (6.1.7.6)
@@ -208,11 +210,13 @@ GEM
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 6.1.7.6) railties (= 6.1.7.6)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3) rails-dom-testing (2.2.0)
activesupport (>= 4.2.0) activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.5.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.19, >= 2.19.1) loofah (~> 2.21)
nokogiri (~> 1.14)
railties (6.1.7.6) railties (6.1.7.6)
actionpack (= 6.1.7.6) actionpack (= 6.1.7.6)
activesupport (= 6.1.7.6) activesupport (= 6.1.7.6)
@@ -220,7 +224,7 @@ GEM
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.1.0)
rbtree (0.4.6) rbtree (0.4.6)
regexp_parser (2.7.0) regexp_parser (2.7.0)
resolv (0.2.2) resolv (0.2.2)
@@ -292,8 +296,9 @@ GEM
activesupport (>= 5.2) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
temple (0.10.0) temple (0.10.0)
thor (1.2.1) thor (1.3.0)
tilt (2.1.0) tilt (2.1.0)
timecop (0.9.8)
timeout (0.3.2) timeout (0.3.2)
turbolinks (5.2.1) turbolinks (5.2.1)
turbolinks-source (~> 5.2) turbolinks-source (~> 5.2)
@@ -306,10 +311,11 @@ GEM
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
zeitwerk (2.6.7) zeitwerk (2.6.13)
PLATFORMS PLATFORMS
arm64-darwin-22 arm64-darwin-22
arm64-darwin-23
ruby ruby
x86_64-linux x86_64-linux
@@ -330,7 +336,7 @@ DEPENDENCIES
dynamic_form dynamic_form
encrypto_signo encrypto_signo
execjs (~> 2.7, < 2.8) execjs (~> 2.7, < 2.8)
factory_bot_rails (~> 4.0) factory_bot_rails
foreman foreman
gelf gelf
haml haml
@@ -356,6 +362,7 @@ DEPENDENCIES
secure_headers secure_headers
sentry-rails sentry-rails
sentry-ruby sentry-ruby
timecop
turbolinks (~> 5) turbolinks (~> 5)
uglifier (>= 1.3.0) uglifier (>= 1.3.0)

عرض الملف

@@ -5,33 +5,34 @@
# Table name: servers # Table name: servers
# #
# id :integer not null, primary key # id :integer not null, primary key
# organization_id :integer # allow_sender :boolean default(FALSE)
# uuid :string(255)
# name :string(255)
# mode :string(255)
# ip_pool_id :integer
# created_at :datetime
# updated_at :datetime
# permalink :string(255)
# send_limit :integer
# deleted_at :datetime # deleted_at :datetime
# domains_not_to_click_track :text(65535)
# log_smtp_data :boolean default(FALSE)
# message_retention_days :integer # message_retention_days :integer
# mode :string(255)
# name :string(255)
# outbound_spam_threshold :decimal(8, 2)
# permalink :string(255)
# postmaster_address :string(255)
# privacy_mode :boolean default(FALSE)
# raw_message_retention_days :integer # raw_message_retention_days :integer
# raw_message_retention_size :integer # raw_message_retention_size :integer
# allow_sender :boolean default(FALSE) # send_limit :integer
# token :string(255)
# send_limit_approaching_at :datetime # send_limit_approaching_at :datetime
# send_limit_approaching_notified_at :datetime # send_limit_approaching_notified_at :datetime
# send_limit_exceeded_at :datetime # send_limit_exceeded_at :datetime
# send_limit_exceeded_notified_at :datetime # send_limit_exceeded_notified_at :datetime
# spam_threshold :decimal(8, 2)
# spam_failure_threshold :decimal(8, 2) # spam_failure_threshold :decimal(8, 2)
# postmaster_address :string(255) # spam_threshold :decimal(8, 2)
# suspended_at :datetime # suspended_at :datetime
# outbound_spam_threshold :decimal(8, 2)
# domains_not_to_click_track :text(65535)
# suspension_reason :string(255) # suspension_reason :string(255)
# log_smtp_data :boolean default(FALSE) # token :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# ip_pool_id :integer
# organization_id :integer
# #
# Indexes # Indexes
# #

عرض الملف

@@ -5,9 +5,9 @@
# Table name: statistics # Table name: statistics
# #
# id :integer not null, primary key # id :integer not null, primary key
# total_messages :bigint(8) default(0) # total_incoming :bigint default(0)
# total_outgoing :bigint(8) default(0) # total_messages :bigint default(0)
# total_incoming :bigint(8) default(0) # total_outgoing :bigint default(0)
# #
class Statistic < ApplicationRecord class Statistic < ApplicationRecord

عرض الملف

@@ -1,5 +1,3 @@
# frozen_string_literal: true
# This file is auto-generated from the current state of the database. Instead # This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to # of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition. # incrementally modify your database, and then regenerate this schema definition.

عرض الملف

@@ -25,7 +25,7 @@ module Postal
@attributes[name.to_s] @attributes[name.to_s]
end end
def respond_to_missing?(name) def respond_to_missing?(name, include_private = false)
@attributes.key?(name.to_s) @attributes.key?(name.to_s)
end end

عرض الملف

@@ -185,7 +185,7 @@ module Postal
end end
end end
def respond_to_missing?(name) def respond_to_missing?(name, include_private = false)
name = name.to_s.sub(/=\z/, "") name = name.to_s.sub(/=\z/, "")
@attributes.key?(name.to_s) @attributes.key?(name.to_s)
end end

عرض الملف

@@ -11,23 +11,28 @@ module Postal
def up def up
end end
def self.run(database, start_from = database.schema_version) def self.run(database, start_from: database.schema_version, silent: false)
files = Dir[Rails.root.join("lib", "postal", "message_db", "migrations", "*.rb")] files = Dir[Rails.root.join("lib", "postal", "message_db", "migrations", "*.rb")]
files = files.map do |f| files = files.map do |f|
id, name = f.split("/").last.split("_", 2) id, name = f.split("/").last.split("_", 2)
[id.to_i, name] [id.to_i, name]
end.sort_by(&:first) end.sort_by(&:first)
latest_version = files.last.first latest_version = files.last.first
if latest_version > start_from if latest_version <= start_from
puts "\e[32mMigrating #{database.database_name} from version #{start_from} => #{files.last.first}\e[0m" puts "Nothing to do" unless silent
else return false
puts "Nothing to do."
end end
unless silent
puts "\e[32mMigrating #{database.database_name} from version #{start_from} => #{files.last.first}\e[0m"
end
files.each do |version, file| files.each do |version, file|
klass_name = file.gsub(/\.rb\z/, "").camelize klass_name = file.gsub(/\.rb\z/, "").camelize
next if start_from >= version next if start_from >= version
puts "\e[45m++ Migrating #{klass_name} (#{version})\e[0m" puts "\e[45m++ Migrating #{klass_name} (#{version})\e[0m" unless silent
require "postal/message_db/migrations/#{version.to_s.rjust(2, '0')}_#{file}" require "postal/message_db/migrations/#{version.to_s.rjust(2, '0')}_#{file}"
klass = Postal::MessageDB::Migrations.const_get(klass_name) klass = Postal::MessageDB::Migrations.const_get(klass_name)
instance = klass.new(database) instance = klass.new(database)

عرض الملف

@@ -14,14 +14,14 @@ module Postal
def provision def provision
drop drop
create create
migrate migrate(silent: true)
end end
# #
# Migrate this database # Migrate this database
# #
def migrate(start_from = @database.schema_version) def migrate(start_from: @database.schema_version, silent: false)
Postal::MessageDB::Migration.run(@database, start_from) Postal::MessageDB::Migration.run(@database, start_from: start_from, silent: silent)
end end
# #

عرض الملف

@@ -11,6 +11,12 @@ module Postal
LOG_REDACTION_STRING = "[redacted]" LOG_REDACTION_STRING = "[redacted]"
attr_reader :logging_enabled 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) def initialize(ip_address)
@logging_enabled = true @logging_enabled = true
@@ -55,19 +61,6 @@ module Postal
end end
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? def finished?
@finished || false @finished || false
end end
@@ -137,7 +130,11 @@ module Postal
@helo_name = data.strip.split(" ", 2)[1] @helo_name = data.strip.split(" ", 2)[1]
transaction_reset transaction_reset
@state = :welcomed @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 end
def helo(data) def helo(data)
@@ -194,7 +191,7 @@ module Postal
"334 UGFzc3dvcmQ6" # "Password:" "334 UGFzc3dvcmQ6" # "Password:"
end end
data = data.gsub!(/AUTH LOGIN ?/i, "") data = data.gsub(/AUTH LOGIN ?/i, "")
if data.strip == "" if data.strip == ""
@proc = username_handler @proc = username_handler
"334 VXNlcm5hbWU6" # "Username:" "334 VXNlcm5hbWU6" # "Username:"
@@ -226,6 +223,7 @@ module Postal
log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m"
next "535 Denied" next "535 Denied"
end end
grant = nil grant = nil
server.credentials.where(type: "SMTP").each do |credential| server.credentials.where(type: "SMTP").each do |credential|
correct_response = OpenSSL::HMAC.hexdigest(CRAM_MD5_DIGEST, credential.key, challenge) 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}" grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}"
break break
end end
if grant.nil? if grant.nil?
log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m"
next "535 Denied" next "535 Denied"
end end
grant grant
end end
@@ -458,6 +458,7 @@ module Postal
msg.mail_from = @mail_from msg.mail_from = @mail_from
msg.raw_message = @data msg.raw_message = @data
msg.received_with_ssl = @tls msg.received_with_ssl = @tls
msg.bounce = 1
end end
else else
# There's no return path route, we just need to insert the mesage # There's no return path route, we just need to insert the mesage
@@ -489,6 +490,19 @@ module Postal
states.include?(@state) states.include?(@state)
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
end end
end end
end end

عرض الملف

@@ -1,48 +1,57 @@
# frozen_string_literal: true
# NOTE: only doing this in development as some production environments (Heroku) # NOTE: only doing this in development as some production environments (Heroku)
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper # NOTE: are sensitive to local FS writes, and besides -- it's just not proper
# NOTE: to have a dev-mode tool do its thing in production. # NOTE: to have a dev-mode tool do its thing in production.
if Rails.env.development? if Rails.env.development?
require 'annotate'
task :set_annotation_options do task :set_annotation_options do
# You can override any of these by setting an environment variable of the # You can override any of these by setting an environment variable of the
# same name. # same name.
Annotate.set_defaults( Annotate.set_defaults(
"routes" => "false", 'active_admin' => 'false',
"position_in_routes" => "before", 'additional_file_patterns' => [],
"position_in_class" => "before", 'routes' => 'false',
"position_in_test" => "before", 'models' => 'true',
"position_in_fixture" => "before", 'position_in_routes' => 'before',
"position_in_factory" => "before", 'position_in_class' => 'before',
"position_in_serializer" => "before", 'position_in_test' => 'before',
"show_foreign_keys" => "true", 'position_in_fixture' => 'before',
"show_indexes" => "true", 'position_in_factory' => 'before',
"simple_indexes" => "false", 'position_in_serializer' => 'before',
"model_dir" => "app/models", 'show_foreign_keys' => 'true',
"root_dir" => "", 'show_complete_foreign_keys' => 'false',
"include_version" => "false", 'show_indexes' => 'true',
"require" => "", 'simple_indexes' => 'false',
"exclude_tests" => "false", 'model_dir' => 'app/models',
"exclude_fixtures" => "false", 'root_dir' => '',
"exclude_factories" => "false", 'include_version' => 'false',
"exclude_serializers" => "false", 'require' => '',
"exclude_scaffolds" => "true", 'exclude_tests' => 'false',
"exclude_controllers" => "true", 'exclude_fixtures' => 'false',
"exclude_helpers" => "true", 'exclude_factories' => 'false',
"ignore_model_sub_dir" => "false", 'exclude_serializers' => 'false',
"ignore_columns" => nil, 'exclude_scaffolds' => 'true',
"ignore_routes" => nil, 'exclude_controllers' => 'true',
"ignore_unknown_models" => "false", 'exclude_helpers' => 'true',
"hide_limit_column_types" => "integer,boolean", 'exclude_sti_subclasses' => 'false',
"skip_on_db_migrate" => "false", 'ignore_model_sub_dir' => 'false',
"format_bare" => "true", 'ignore_columns' => nil,
"format_rdoc" => "false", 'ignore_routes' => nil,
"format_markdown" => "false", 'ignore_unknown_models' => 'false',
"sort" => "false", 'hide_limit_column_types' => 'integer,bigint,boolean',
"force" => "false", 'hide_default_column_types' => 'json,jsonb,hstore',
"trace" => "false", 'skip_on_db_migrate' => 'false',
"wrapper_open" => nil, 'format_bare' => 'true',
"wrapper_close" => nil 'format_rdoc' => 'false',
'format_yard' => 'false',
'format_markdown' => 'false',
'sort' => 'false',
'force' => 'false',
'frozen' => 'false',
'classified_sort' => 'true',
'trace' => 'false',
'wrapper_open' => nil,
'wrapper_close' => nil,
'with_comment' => 'true'
) )
end end

عرض الملف

@@ -0,0 +1,25 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: credentials
#
# id :integer not null, primary key
# hold :boolean default(FALSE)
# key :string(255)
# last_used_at :datetime
# name :string(255)
# options :text(65535)
# type :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# server_id :integer
#
FactoryBot.define do
factory :credential do
server
name { "Example Credential" }
type { "API" }
end
end

عرض الملف

@@ -38,7 +38,7 @@
FactoryBot.define do FactoryBot.define do
factory :domain do factory :domain do
association :owner, factory: :user association :owner, factory: :organization
sequence(:name) { |n| "example#{n}.com" } sequence(:name) { |n| "example#{n}.com" }
verification_method { "DNS" } verification_method { "DNS" }
verified_at { Time.now } verified_at { Time.now }

عرض الملف

@@ -0,0 +1,31 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: http_endpoints
#
# id :integer not null, primary key
# disabled_until :datetime
# encoding :string(255)
# error :text(65535)
# format :string(255)
# include_attachments :boolean default(TRUE)
# last_used_at :datetime
# name :string(255)
# strip_replies :boolean default(FALSE)
# timeout :integer
# url :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# server_id :integer
#
FactoryBot.define do
factory :http_endpoint do
server
name { "HTTP endpoint" }
url { "https://example.com/endpoint" }
encoding { "BodyAsJSON" }
format { "Hash" }
end
end

عرض الملف

@@ -0,0 +1,35 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: routes
#
# id :integer not null, primary key
# endpoint_type :string(255)
# mode :string(255)
# name :string(255)
# spam_mode :string(255)
# token :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# domain_id :integer
# endpoint_id :integer
# server_id :integer
#
# Indexes
#
# index_routes_on_token (token)
#
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

عرض الملف

@@ -5,33 +5,34 @@
# Table name: servers # Table name: servers
# #
# id :integer not null, primary key # id :integer not null, primary key
# organization_id :integer # allow_sender :boolean default(FALSE)
# uuid :string(255)
# name :string(255)
# mode :string(255)
# ip_pool_id :integer
# created_at :datetime
# updated_at :datetime
# permalink :string(255)
# send_limit :integer
# deleted_at :datetime # deleted_at :datetime
# domains_not_to_click_track :text(65535)
# log_smtp_data :boolean default(FALSE)
# message_retention_days :integer # message_retention_days :integer
# mode :string(255)
# name :string(255)
# outbound_spam_threshold :decimal(8, 2)
# permalink :string(255)
# postmaster_address :string(255)
# privacy_mode :boolean default(FALSE)
# raw_message_retention_days :integer # raw_message_retention_days :integer
# raw_message_retention_size :integer # raw_message_retention_size :integer
# allow_sender :boolean default(FALSE) # send_limit :integer
# token :string(255)
# send_limit_approaching_at :datetime # send_limit_approaching_at :datetime
# send_limit_approaching_notified_at :datetime # send_limit_approaching_notified_at :datetime
# send_limit_exceeded_at :datetime # send_limit_exceeded_at :datetime
# send_limit_exceeded_notified_at :datetime # send_limit_exceeded_notified_at :datetime
# spam_threshold :decimal(8, 2)
# spam_failure_threshold :decimal(8, 2) # spam_failure_threshold :decimal(8, 2)
# postmaster_address :string(255) # spam_threshold :decimal(8, 2)
# suspended_at :datetime # suspended_at :datetime
# outbound_spam_threshold :decimal(8, 2)
# domains_not_to_click_track :text(65535)
# suspension_reason :string(255) # suspension_reason :string(255)
# log_smtp_data :boolean default(FALSE) # token :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# ip_pool_id :integer
# organization_id :integer
# #
# Indexes # Indexes
# #
@@ -48,5 +49,9 @@ FactoryBot.define do
mode { "Live" } mode { "Live" }
provision_database { false } provision_database { false }
sequence(:permalink) { |n| "server#{n}" } sequence(:permalink) { |n| "server#{n}" }
trait :suspended do
suspended_at { Time.current }
end
end end
end end

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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 "rspec/rails"
require "spec_helper" require "spec_helper"
require "factory_bot" require "factory_bot"
require "timecop"
require "database_cleaner" require "database_cleaner"
DatabaseCleaner.allow_remote_database_url = true DatabaseCleaner.allow_remote_database_url = true