rubymta 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +3 -0
- data/CHANGELOG.md +0 -0
- data/README.md +345 -0
- data/lib/rubymta/base-x.rb +47 -0
- data/lib/rubymta/contact.rb +100 -0
- data/lib/rubymta/deepclone.rb +23 -0
- data/lib/rubymta/extended_classes.rb +168 -0
- data/lib/rubymta/item_of_mail.rb +113 -0
- data/lib/rubymta/queue_runner.rb +376 -0
- data/lib/rubymta/receiver.rb +615 -0
- data/lib/rubymta/server.rb +306 -0
- data/lib/rubymta/version.rb +5 -0
- data/lib/rubymta.rb +2 -0
- data/rubymta.gemspec +15 -0
- data/spec/coco +3 -0
- data/spec/rubymta.rb +199 -0
- metadata +60 -0
@@ -0,0 +1,306 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'socket'
|
3
|
+
require 'ostruct'
|
4
|
+
require 'optparse'
|
5
|
+
require 'openssl'
|
6
|
+
require 'sequel'
|
7
|
+
require 'sqlite3'
|
8
|
+
require 'etc'
|
9
|
+
require 'pretty_inspect'
|
10
|
+
|
11
|
+
class Terminate < Exception; end
|
12
|
+
class Quit < Exception; end
|
13
|
+
|
14
|
+
# Set up the $app hash for systemwide parameters
|
15
|
+
$app = {}
|
16
|
+
$app[:path] = Dir::pwd
|
17
|
+
$app[:mode] = ENV['MODE']
|
18
|
+
|
19
|
+
require './config'
|
20
|
+
include Config
|
21
|
+
$app[:uinfo] = Etc::getpwnam(UserName) if UserName
|
22
|
+
$app[:ginfo] = Etc::getgrnam(GroupName) if GroupName
|
23
|
+
|
24
|
+
require_relative 'receiver'
|
25
|
+
|
26
|
+
# get setup and open the log
|
27
|
+
LOG = Logger::new(LogPathAndFile, LogFileLife)
|
28
|
+
LOG.formatter = proc do |severity, datetime, progname, msg|
|
29
|
+
pname = if progname then '('+progname+') ' else nil end
|
30
|
+
"#{datetime.strftime("%Y-%m-%d %H:%M:%S")} [#{severity}] #{pname}#{msg}\n"
|
31
|
+
end
|
32
|
+
|
33
|
+
# Make sure the MODE environmental variable is valid
|
34
|
+
if ['dev','live'].index(ENV['MODE']).nil?
|
35
|
+
msg = "Environmental variable MODE not set properly--must be dev or live"
|
36
|
+
LOG.fatal(msg)
|
37
|
+
puts msg
|
38
|
+
exit(1)
|
39
|
+
end
|
40
|
+
|
41
|
+
# set the owner of the LOG file to UserName and
|
42
|
+
# GroupName--this is because otherwise, a new log file
|
43
|
+
# will be created as root:root, and run_queue won't be
|
44
|
+
# able to open it--this only is needed if 'server' is
|
45
|
+
# running as root; same for the sqlite database
|
46
|
+
if !UserName.nil?
|
47
|
+
File::chown($app[:uinfo].uid, $app[:ginfo].gid, LogPathAndFile)
|
48
|
+
File::chown($app[:uinfo].uid, $app[:ginfo].gid, S3DBPath)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Open the sqlite3 database for rubymta use
|
52
|
+
S3DB = Sequel.connect("sqlite://#{S3DBPath}")
|
53
|
+
LOG.info("Database '#{S3DBPath}' opened")
|
54
|
+
|
55
|
+
# Create the tables we need if they don't already exist
|
56
|
+
|
57
|
+
# The contacts table is just used to count the number of 'hits' for
|
58
|
+
# a particular IP address in order to choose between sending an
|
59
|
+
# "Access TEMPORARILY denied" or just slam the port shut
|
60
|
+
S3DB::create_table?(:contacts) do
|
61
|
+
primary_key(:id)
|
62
|
+
string(:remote_ip)
|
63
|
+
integer(:hits)
|
64
|
+
integer(:locks)
|
65
|
+
integer(:violations)
|
66
|
+
datetime(:expires_at)
|
67
|
+
datetime(:created_at)
|
68
|
+
datetime(:updated_at)
|
69
|
+
index(:remote_ip)
|
70
|
+
end
|
71
|
+
|
72
|
+
# The queue table is used to track the emails server/receiver receive. The
|
73
|
+
# queue runner will read this queue and attempt to deliver the emails.
|
74
|
+
S3DB::create_table?(:parcels) do
|
75
|
+
primary_key(:id)
|
76
|
+
string(:mail_id)
|
77
|
+
string(:from_url)
|
78
|
+
string(:to_url)
|
79
|
+
string(:delivery) # 'local' or 'remote'
|
80
|
+
datetime(:delivery_at)
|
81
|
+
text(:delivery_msg)
|
82
|
+
datetime(:retry_at)
|
83
|
+
datetime(:created_at)
|
84
|
+
datetime(:updated_at)
|
85
|
+
index([:mail_id,:from_url])
|
86
|
+
index([:mail_id,:to_url])
|
87
|
+
end
|
88
|
+
|
89
|
+
# load the DKIM private key for this domain, if any
|
90
|
+
$app[:dkim] = nil
|
91
|
+
File::open(DKIMPrivateKeyFile, "r") { |f| $app[:dkim] = f.read } if DKIMPrivateKeyFile
|
92
|
+
|
93
|
+
# MAIN server class -- starts at Server#start
|
94
|
+
class Server
|
95
|
+
|
96
|
+
include Socket::Constants
|
97
|
+
|
98
|
+
# def restart
|
99
|
+
# # handle a HUP request here
|
100
|
+
# end
|
101
|
+
|
102
|
+
# this is the code executed after the process has been
|
103
|
+
# forked and root privileges have been dropped
|
104
|
+
def process_call(connection, local_port, remote_port, remote_ip, remote_hostname, remote_service)
|
105
|
+
begin
|
106
|
+
Signal.trap("INT") { } # ignore ^C in the child process
|
107
|
+
LOG.info("%06d"%Process::pid) {"Connection accepted on port #{local_port} from port #{remote_port} at #{remote_ip} (#{remote_hostname})"}
|
108
|
+
|
109
|
+
# a new object is created here to provide separation between server and receiver
|
110
|
+
# this call receives the email and does basic validation
|
111
|
+
Receiver::new(connection).receive(local_port, Socket::gethostname, remote_port, remote_hostname, remote_ip)
|
112
|
+
rescue Quit
|
113
|
+
# nothing to do here
|
114
|
+
ensure
|
115
|
+
# close the database (the child's copy)
|
116
|
+
S3DB.disconnect if S3DB
|
117
|
+
nil # don't return the Receiver object
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# this method drops the process's root privileges for security reasons
|
122
|
+
def drop_root_privileges
|
123
|
+
if Process::Sys.getuid==0
|
124
|
+
Dir.chdir($app[:path]) if not $app[:path].nil?
|
125
|
+
Process::GID.change_privilege($app[:ginfo].gid)
|
126
|
+
Process::UID.change_privilege($app[:uinfo].uid)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# both the AF_INET and AF_INET6 families use this DRY method
|
131
|
+
def bind_socket(family,port,ip)
|
132
|
+
socket = Socket.new(family, SOCK_STREAM, 0)
|
133
|
+
sockaddr = Socket.sockaddr_in(port.to_i,ip)
|
134
|
+
socket.setsockopt(:SOCKET, :REUSEADDR, true)
|
135
|
+
socket.bind(sockaddr)
|
136
|
+
socket.listen(0)
|
137
|
+
return socket
|
138
|
+
end
|
139
|
+
|
140
|
+
# the listening thread is established in this method depending on the ListenPort
|
141
|
+
# argument passed to it -- it can be '<ipv6>/<port>', '<ipv4>:<port>', or just '<port>'
|
142
|
+
def listening_thread(local_port)
|
143
|
+
LOG.info("%06d"%Process::pid) {"listening on port #{local_port}..."}
|
144
|
+
|
145
|
+
# establish an SSL context
|
146
|
+
$ctx = OpenSSL::SSL::SSLContext.new
|
147
|
+
$ctx.key = $prv
|
148
|
+
$ctx.cert = $crt
|
149
|
+
|
150
|
+
# check the parameter to see if it's valid
|
151
|
+
m = /^(([0-9a-fA-F]{0,4}:{0,1}){1,8})\/([0-9]{1,5})|(([0-9]{1,3}\.{0,1}){4}):([0-9]{1,5})|([0-9]{1,5})$/.match(local_port)
|
152
|
+
#<MatchData "2001:4800:7817:104:be76:4eff:fe05:3b18/2000" 1:"2001:4800:7817:104:be76:4eff:fe05:3b18" 2:"3b18" 3:"2000" 4:nil 5:nil 6:nil 7:nil>
|
153
|
+
#<MatchData "23.253.107.107:2000" 1:nil 2:nil 3:nil 4:"23.253.107.107" 5:"107" 6:"2000" 7:nil>
|
154
|
+
#<MatchData "2000" 1:nil 2:nil 3:nil 4:nil 5:nil 6:nil 7:"2000">
|
155
|
+
case
|
156
|
+
when !m[1].nil? # its AF_INET6
|
157
|
+
socket = bind_socket(AF_INET6,m[3],m[1])
|
158
|
+
when !m[4].nil? # its AF_INET
|
159
|
+
socket = bind_socket(AF_INET,m[6],m[4])
|
160
|
+
when !m[7].nil?
|
161
|
+
socket = bind_socket(AF_INET6,m[7],"0:0:0:0:0:0:0:0")
|
162
|
+
else
|
163
|
+
raise ArgumentError.new(local_port)
|
164
|
+
end
|
165
|
+
ssl_server = OpenSSL::SSL::SSLServer.new(socket, $ctx);
|
166
|
+
|
167
|
+
# main listening loop starts in non-encrypted mode
|
168
|
+
ssl_server.start_immediately = false
|
169
|
+
loop do
|
170
|
+
# we can't use threads because if we drop root privileges on any thread,
|
171
|
+
# they will be dropped for all threads in the process--so we have to fork
|
172
|
+
# a process here in order that the reception be able to drop root privileges
|
173
|
+
# and run at a user level--this is a security precaution
|
174
|
+
connection = ssl_server.accept
|
175
|
+
Process::fork do
|
176
|
+
begin
|
177
|
+
drop_root_privileges if !UserName.nil?
|
178
|
+
remote_hostname, remote_service = connection.io.remote_address.getnameinfo
|
179
|
+
remote_ip, remote_port = connection.io.remote_address.ip_unpack
|
180
|
+
process_call(connection, local_port, remote_port.to_s, remote_ip, remote_hostname, remote_service)
|
181
|
+
LOG.info("%06d"%Process::pid) {"Connection closed on port #{local_port} by #{ServerName}"}
|
182
|
+
rescue Errno::ENOTCONN => e
|
183
|
+
LOG.info("%06d"%Process::pid) {"Remote Port scan on port #{local_port}"}
|
184
|
+
ensure
|
185
|
+
# here we close the child's copy of the connection --
|
186
|
+
# since the parent already closed it's copy, this
|
187
|
+
# one will send a FIN to the client, so the client
|
188
|
+
# can terminate gracefully
|
189
|
+
connection.close
|
190
|
+
# and finally, close the child's link to the log
|
191
|
+
LOG.close
|
192
|
+
end
|
193
|
+
end
|
194
|
+
# here we close the parent's copy of the connection --
|
195
|
+
# the child (created by the Process::fork above) has another copy --
|
196
|
+
# if this one is not closed, when the child closes it's copy,
|
197
|
+
# the child's copy won't send a FIN to the client -- the FIN
|
198
|
+
# is only sent when the last process holding a copy to the
|
199
|
+
# socket closes it's copy
|
200
|
+
connection.close
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# this method parses the command line options
|
205
|
+
def process_options
|
206
|
+
options = OpenStruct.new
|
207
|
+
options.log = Logger::INFO
|
208
|
+
options.daemonize = false
|
209
|
+
begin
|
210
|
+
OptionParser.new do |opts|
|
211
|
+
opts.on("--debug", "Log all messages") { |v| options.log = Logger::DEBUG }
|
212
|
+
opts.on("--info", "Log all messages") { |v| options.log = Logger::INFO }
|
213
|
+
opts.on("--warn", "Log all messages") { |v| options.log = Logger::WARN }
|
214
|
+
opts.on("--error", "Log all messages") { |v| options.log = Logger::ERROR }
|
215
|
+
opts.on("--fatal", "Log all messages") { |v| options.log = Logger::FATAL }
|
216
|
+
opts.on("--daemonize", "Run as system daemon") { |v| options.daemonize = true }
|
217
|
+
end.parse!
|
218
|
+
rescue OptionParser::InvalidOption => e
|
219
|
+
LOG.warn("%06d"%Process::pid) {"#{e.inspect}"}
|
220
|
+
end
|
221
|
+
options
|
222
|
+
end # process_options
|
223
|
+
|
224
|
+
def start
|
225
|
+
# generate the first log messages
|
226
|
+
LOG.info("%06d"%Process::pid) {"Starting RubyMTA at #{Time.now.strftime("%Y-%m-%d %H:%M:%S %Z")}, pid=#{Process::pid}"}
|
227
|
+
LOG.info("%06d"%Process::pid) {"Options specified: #{ARGV.join(", ")}"} if ARGV.size>0
|
228
|
+
|
229
|
+
# get the options from the command line
|
230
|
+
@options = process_options
|
231
|
+
LOG.level = @options.log
|
232
|
+
|
233
|
+
# get the certificates, if any; they're needed for STARTTLS
|
234
|
+
# we do this before daemonizing because the working folder might change
|
235
|
+
$prv = if PrivateKey then OpenSSL::PKey::RSA.new File.read(PrivateKey) else nil end
|
236
|
+
$crt = if Certificate then OpenSSL::X509::Certificate.new File.read(Certificate) else nil end
|
237
|
+
|
238
|
+
# daemonize it if the option was set--it doesn't have to be root to daemonize it
|
239
|
+
Process::daemon if @options.daemonize
|
240
|
+
|
241
|
+
# get the process ID and the user id AFTER demonizing, if that was requested
|
242
|
+
pid = Process::pid
|
243
|
+
uid = Process::Sys.getuid
|
244
|
+
gid = Process::Sys.getgid
|
245
|
+
|
246
|
+
LOG.info("%06d"%Process::pid) {"Daemonized at #{Time.now.strftime("%Y-%m-%d %H:%M:%S %Z")}, pid=#{pid}, uid=#{uid}, gid=#{gid}"} #if @options.daemonize
|
247
|
+
|
248
|
+
# store the pid of the server session
|
249
|
+
begin
|
250
|
+
LOG.info("%06d"%Process::pid) {"RubyMTA running as PID=>#{pid}, UID=>#{uid}, GID=>#{gid}"}
|
251
|
+
File::open("#{PidPath}/rubymta.pid","w") { |f| f.write(pid.to_s) }
|
252
|
+
rescue Errno::EACCES => e
|
253
|
+
LOG.warn("%06d"%Process::pid) {"The pid couldn't be written. To save the pid, create a directory '#{PidPath}' with r/w permissions for this user."}
|
254
|
+
LOG.warn("%06d"%Process::pid) {"Proceeding without writing the pid."}
|
255
|
+
end
|
256
|
+
|
257
|
+
# if rubymta was started as root, make sure UserName and
|
258
|
+
# GroupName have values because we have to drop root privileges
|
259
|
+
# after we fork a process for the receiver
|
260
|
+
if uid==0 # it's root
|
261
|
+
if UserName.nil? || GroupName.nil?
|
262
|
+
LOG.error("%06d"%Process::pid) {"rubymta can't be started as root unless UserName and GroupName are set."}
|
263
|
+
exit(1)
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
# this is the main loop which runs until admin enters ^C
|
268
|
+
Signal.trap("INT") { raise Terminate.new }
|
269
|
+
Signal.trap("HUP") { restart if defined?(restart) }
|
270
|
+
Signal.trap("CHLD") do
|
271
|
+
begin
|
272
|
+
Process.wait(-1, Process::WNOHANG)
|
273
|
+
rescue Errno::ECHILD => e
|
274
|
+
# ignore the error
|
275
|
+
end
|
276
|
+
end
|
277
|
+
threads = []
|
278
|
+
# start the server on multiple ports (the usual case)
|
279
|
+
begin
|
280
|
+
ListeningPorts.each do |port|
|
281
|
+
threads << Thread.start(port) do |port|
|
282
|
+
listening_thread(port)
|
283
|
+
end
|
284
|
+
end
|
285
|
+
# the joins are done ONLY after all threads are started
|
286
|
+
threads.each { |thread| thread.join }
|
287
|
+
rescue Terminate
|
288
|
+
LOG.info("%06d"%Process::pid) {"#{ServerName} terminated by admin ^C"}
|
289
|
+
end
|
290
|
+
|
291
|
+
ensure
|
292
|
+
|
293
|
+
# attempt to remove the pid file
|
294
|
+
begin
|
295
|
+
File.delete("#{PidPath}/rubymta.pid")
|
296
|
+
rescue Errno::ENOENT => e
|
297
|
+
LOG.warn("%06d"%Process::pid) {"No such file: #{e.inspect}"}
|
298
|
+
rescue Errno::EACCES, Errno::EPERM
|
299
|
+
LOG.warn("%06d"%Process::pid) {"Permission denied: #{e.inspect}"}
|
300
|
+
end
|
301
|
+
|
302
|
+
# close the log
|
303
|
+
LOG.close if LOG
|
304
|
+
end
|
305
|
+
|
306
|
+
end
|
data/lib/rubymta.rb
ADDED
data/rubymta.gemspec
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
require_relative 'lib/rubymta/version'
|
2
|
+
include Version
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.author = "Michael J. Welch, Ph.D."
|
5
|
+
s.files = Dir.glob(["CHANGELOG.md", "LICENSE.md", "README.md", "rubymta.gemspec", "lib/*", "lib/rubymta/*", "spec/*", ".gitignore"])
|
6
|
+
s.name = 'rubymta'
|
7
|
+
s.require_paths = ["lib"]
|
8
|
+
s.summary = "A Ruby Gem providing a complete Mail Transport Agent package."
|
9
|
+
s.version = VERSION
|
10
|
+
s.date = MODIFIED
|
11
|
+
s.email = 'mjwelchphd@gmail.com'
|
12
|
+
s.homepage = 'http://rubygems.org/gems/rubymta'
|
13
|
+
s.license = 'MIT'
|
14
|
+
s.description = "RubyMta is an experimental mail transport agent written in Ruby. See the README."
|
15
|
+
end
|
data/spec/coco
ADDED
data/spec/rubymta.rb
ADDED
@@ -0,0 +1,199 @@
|
|
1
|
+
#require "bacon"
|
2
|
+
require 'sequel'
|
3
|
+
require "sqlite3"
|
4
|
+
|
5
|
+
Sender = "mjwelchphd@gmail.com"
|
6
|
+
Recipient = "coco@tzarmail.com"
|
7
|
+
S3DBPath = "gmta-dev.db"
|
8
|
+
RemoteIP = "2001:4800:7811:513:be76:4eff:fe04:f0b4"
|
9
|
+
EhloDomainRequired = true
|
10
|
+
|
11
|
+
|
12
|
+
# Open the sqlite3 database for rubymta use
|
13
|
+
S3DB = Sequel.connect("sqlite://#{S3DBPath}")
|
14
|
+
|
15
|
+
# swaks -tls -s mail.tzarmail.com:25 -t mike@tzarmail.com -f mjwelchphd@gmail.com --ehlo bluecrapduck345at789zappo.com
|
16
|
+
|
17
|
+
# {:id=>5, :remote_ip=>"::ffff:107.185.187.182", :hits=>142, :locks=>1, :violations=>1,
|
18
|
+
# :expires_at=>2017-07-07 22:44:52 +0000, :created_at=>2017-05-22 19:58:51 +0000,
|
19
|
+
# :updated_at=>2017-07-07 22:41:11 +0000}
|
20
|
+
ds = S3DB[:contacts].where(:remote_ip=>RemoteIP)
|
21
|
+
|
22
|
+
describe "Rubymta Gem 'contacts table' Tests" do
|
23
|
+
|
24
|
+
# be sure we can set/reset the contacts table
|
25
|
+
it "reset the test contact record" do
|
26
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
27
|
+
rs = ds.select(:locks, :violations).first
|
28
|
+
(rs.inspect).should.be.equal "{:locks=>0, :violations=>0}"
|
29
|
+
end
|
30
|
+
|
31
|
+
# send a simple email just as a sanity check
|
32
|
+
it "the MTA should accept a simple email" do
|
33
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t #{Recipient} -f #{Sender}`
|
34
|
+
ok = response.index("250 2.0.0 OK id=")
|
35
|
+
ok.should.not.equal 0
|
36
|
+
end
|
37
|
+
|
38
|
+
# 1st violation -- reject a relay attempt
|
39
|
+
it "1st violation -- reject a relay attempt" do
|
40
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t mike.w@tzarmail.com -f #{Sender}`
|
41
|
+
ok = response.index("556 5.7.27 Czar Mail does not support relaying")
|
42
|
+
ok.should.not.equal 0
|
43
|
+
end
|
44
|
+
|
45
|
+
# the first failure should have set violations=1
|
46
|
+
it "should have 1 violation" do
|
47
|
+
rs = ds.select(:locks, :violations).first
|
48
|
+
(rs.inspect).should.be.equal "{:locks=>0, :violations=>1}"
|
49
|
+
end
|
50
|
+
|
51
|
+
# 2nd violation -- reject a relay attempt
|
52
|
+
it "2nd violation -- reject a relay attempt" do
|
53
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t mike.w@tzarmail.com -f #{Sender}`
|
54
|
+
ok = response.index("556 5.7.27 Czar Mail does not support relaying")
|
55
|
+
ok.should.not.equal 0
|
56
|
+
end
|
57
|
+
|
58
|
+
# the second failure should have set violations=2
|
59
|
+
it "should have 2 violations" do
|
60
|
+
rs = ds.select(:locks, :violations).first
|
61
|
+
(rs.inspect).should.be.equal "{:locks=>0, :violations=>2}"
|
62
|
+
end
|
63
|
+
|
64
|
+
# 3rd violation -- reject a relay attempt
|
65
|
+
it "3rd violation -- reject a relay attempt" do
|
66
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t mike.w@tzarmail.com -f #{Sender}`
|
67
|
+
ok = response.index("556 5.7.27 Czar Mail does not support relaying")
|
68
|
+
ok.should.not.equal 0
|
69
|
+
end
|
70
|
+
|
71
|
+
# the third failure should have set violations=3
|
72
|
+
it "should have 3 violations" do
|
73
|
+
rs = ds.select(:locks, :violations).first
|
74
|
+
(rs.inspect).should.be.equal "{:locks=>0, :violations=>3}"
|
75
|
+
end
|
76
|
+
|
77
|
+
# the 4th violation should respond with a message and set the lock
|
78
|
+
it "the 4th violation should set the lock" do
|
79
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t mike.w@tzarmail.com -f #{Sender}`
|
80
|
+
ok = response.index("454 4.7.1 Access TEMPORARILY denied")
|
81
|
+
ok.should.not.equal 0
|
82
|
+
end
|
83
|
+
|
84
|
+
# the fourth failure should have set violations=4 and locks=1
|
85
|
+
it "should have 4 violations and 1 lock" do
|
86
|
+
rs = ds.select(:locks, :violations).first
|
87
|
+
(rs.inspect).should.be.equal "{:locks=>1, :violations=>4}"
|
88
|
+
end
|
89
|
+
|
90
|
+
# the 5th (and following) violations should have to door slammed
|
91
|
+
it "5th violation -- slam the connection shut" do
|
92
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t mike.w@tzarmail.com -f #{Sender}`
|
93
|
+
ok = response.end_with?("=== Connected to mail.tzarmail.com.\n")
|
94
|
+
ok.inspect.should.equal "true"
|
95
|
+
end
|
96
|
+
|
97
|
+
# the 5th failure doesn't change the lock or violations
|
98
|
+
it "5th failure should still have 4 violations and 1 lock" do
|
99
|
+
rs = ds.select(:locks, :violations).first
|
100
|
+
(rs.inspect).should.be.equal "{:locks=>1, :violations=>4}"
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
|
105
|
+
describe "Rubymta Gem EHLO Tests" do
|
106
|
+
|
107
|
+
if EhloDomainRequired
|
108
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
109
|
+
it "EHLO requires a domain" do
|
110
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t #{Recipient} -f #{Sender} --ehlo 'not-valid-domain'`
|
111
|
+
ok = response.index("454 4.7.1 Access TEMPORARILY denied")
|
112
|
+
ok.should.not.equal 0
|
113
|
+
end
|
114
|
+
|
115
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
116
|
+
it "the EHLO domain must be found in the DNS" do
|
117
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t #{Recipient} -f #{Sender} --ehlo 'domain-not-in-dns.lost'`
|
118
|
+
ok = response.index("was not found in the DNS system")
|
119
|
+
ok.should.not.equal 0
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
124
|
+
|
125
|
+
describe "Rubymta Gem MAIL FROM Tests" do
|
126
|
+
|
127
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
128
|
+
it "the MAIL FROM line must have a sender" do
|
129
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t #{Recipient} -f mr-nobody`
|
130
|
+
ok = response.index("No proper sender (<...>) on the MAIL part line")
|
131
|
+
ok.should.not.equal 0
|
132
|
+
end
|
133
|
+
|
134
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
135
|
+
it "the MAIL FROM sender cannot have '.' as the first character" do
|
136
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t #{Recipient} -f .dennis@gmail.com`
|
137
|
+
ok = response.index("550 5.1.7 beginning or ending '.' or 2 or more '.'s in a row")
|
138
|
+
ok.should.not.equal 0
|
139
|
+
end
|
140
|
+
|
141
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
142
|
+
it "the MAIL FROM sender cannot have '.' as the last character" do
|
143
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t #{Recipient} -f dennis.@gmail.com`
|
144
|
+
ok = response.index("550 5.1.7 beginning or ending '.' or 2 or more '.'s in a row")
|
145
|
+
ok.should.not.equal 0
|
146
|
+
end
|
147
|
+
|
148
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
149
|
+
it "the MAIL FROM sender cannot have two '.'s in a row" do
|
150
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t #{Recipient} -f dennis..george@gmail.com`
|
151
|
+
ok = response.index("550 5.1.7 beginning or ending '.' or 2 or more '.'s in a row")
|
152
|
+
ok.should.not.equal 0
|
153
|
+
end
|
154
|
+
|
155
|
+
# send a MAIL FROM <a member>
|
156
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
157
|
+
it "the MTA expects member mail on port 587" do
|
158
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t #{Sender} -f #{Recipient}`
|
159
|
+
ok = response.index("556 5.7.27 Czar Mail members must use port 587 to send mail")
|
160
|
+
ok.should.not.equal 0
|
161
|
+
end
|
162
|
+
|
163
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
164
|
+
it "traffic on port 587 must be authenticated" do
|
165
|
+
response = `swaks -tls -s mail.tzarmail.com:587 -t #{Sender} -f #{Recipient}`
|
166
|
+
ok = response.index("556 5.7.27 Traffic on port 587 must be authenticated")
|
167
|
+
ok.should.not.equal 0
|
168
|
+
end
|
169
|
+
|
170
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
171
|
+
it "traffic on port 587 needs to be encrypted" do
|
172
|
+
response = `swaks -a PLAIN -s mail.tzarmail.com:587 -t #{Sender} -f #{Recipient} < spec/coco`
|
173
|
+
ok = response.index("556 5.7.27 Traffic on port 587 must be encrypted")
|
174
|
+
ok.should.not.equal 0
|
175
|
+
end
|
176
|
+
|
177
|
+
# send mail from a <non-member>
|
178
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
179
|
+
it "the MTA expects non-member mail on port 25" do
|
180
|
+
response = `swaks -tls -s mail.tzarmail.com:587 -t #{Recipient} -f #{Sender}`
|
181
|
+
ok = response.index("556 5.7.27 Non Czar Mail members must use port 25 to send mail")
|
182
|
+
ok.should.not.equal 0
|
183
|
+
end
|
184
|
+
|
185
|
+
end
|
186
|
+
|
187
|
+
describe "Rubymta Gem RCPT TO Tests" do
|
188
|
+
|
189
|
+
ds.update(:locks=>0, :violations=>0, :expires_at=>Time.now)
|
190
|
+
it "the RCPT TO line must have a recipient" do
|
191
|
+
response = `swaks -tls -s mail.tzarmail.com:25 -t mrs-nobody -f #{Sender}`
|
192
|
+
ok = response.index("No proper recipient (<...>) on the RCPT part line")
|
193
|
+
ok.should.not.equal 0
|
194
|
+
end
|
195
|
+
|
196
|
+
end
|
197
|
+
|
198
|
+
# There is only 1 unreachable test in DATA (I think it's unreachable),
|
199
|
+
# so the DATA tests here have been omitted.
|
metadata
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rubymta
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Michael J. Welch, Ph.D.
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-07-29 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: RubyMta is an experimental mail transport agent written in Ruby. See
|
14
|
+
the README.
|
15
|
+
email: mjwelchphd@gmail.com
|
16
|
+
executables: []
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- ".gitignore"
|
21
|
+
- CHANGELOG.md
|
22
|
+
- README.md
|
23
|
+
- lib/rubymta.rb
|
24
|
+
- lib/rubymta/base-x.rb
|
25
|
+
- lib/rubymta/contact.rb
|
26
|
+
- lib/rubymta/deepclone.rb
|
27
|
+
- lib/rubymta/extended_classes.rb
|
28
|
+
- lib/rubymta/item_of_mail.rb
|
29
|
+
- lib/rubymta/queue_runner.rb
|
30
|
+
- lib/rubymta/receiver.rb
|
31
|
+
- lib/rubymta/server.rb
|
32
|
+
- lib/rubymta/version.rb
|
33
|
+
- rubymta.gemspec
|
34
|
+
- spec/coco
|
35
|
+
- spec/rubymta.rb
|
36
|
+
homepage: http://rubygems.org/gems/rubymta
|
37
|
+
licenses:
|
38
|
+
- MIT
|
39
|
+
metadata: {}
|
40
|
+
post_install_message:
|
41
|
+
rdoc_options: []
|
42
|
+
require_paths:
|
43
|
+
- lib
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - ">="
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: '0'
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
requirements: []
|
55
|
+
rubyforge_project:
|
56
|
+
rubygems_version: 2.5.1
|
57
|
+
signing_key:
|
58
|
+
specification_version: 4
|
59
|
+
summary: A Ruby Gem providing a complete Mail Transport Agent package.
|
60
|
+
test_files: []
|