rubymta 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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: []
|