percolate-mail 1.0.0
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.
- data/examples/percolate-chuckmail +36 -0
- data/lib/percolate/listener.rb +140 -0
- data/lib/percolate/mail_object.rb +106 -0
- data/lib/percolate/responder.rb +347 -0
- data/lib/percolate-mail.rb +1 -0
- data/test/test_percolate_listener.rb +220 -0
- data/test/test_percolate_responder.rb +358 -0
- data/test/unittest_percolate_listener.rb +77 -0
- metadata +54 -0
@@ -0,0 +1,36 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
|
3
|
+
require 'optparse'
|
4
|
+
require 'percolate-mail'
|
5
|
+
|
6
|
+
opts = OptionParser.new do |o|
|
7
|
+
o.on("-p PORT", "--port PORT", Integer,
|
8
|
+
"Accept connections on port PORT (defaults to 10025)") do |port|
|
9
|
+
$port = port
|
10
|
+
end
|
11
|
+
|
12
|
+
o.on("-i IPADDR", "--ip IPADDRESS", String,
|
13
|
+
"Bind to IP address IPADDR (defaults to 0.0.0.0)") do |ip|
|
14
|
+
$ip = ip
|
15
|
+
end
|
16
|
+
|
17
|
+
o.on_tail("-h", "--help", "Show this message") do
|
18
|
+
puts "A lovely little Ruby implementation of that famous BOFH"
|
19
|
+
puts "script \"chuckmail\". This is what percolate-mail does"
|
20
|
+
puts "by default, because I'm too frightened to have it actually"
|
21
|
+
puts "deliver mail by default on account of the horror that could"
|
22
|
+
puts "be unleashed at that."
|
23
|
+
puts ""
|
24
|
+
puts "What chuckmail does is: it simulates an open relay. But it"
|
25
|
+
puts "just discards all messages delivered to it. Very handy if"
|
26
|
+
puts "you want to catch a spammer in the act."
|
27
|
+
puts ""
|
28
|
+
puts opts
|
29
|
+
exit
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
opts.parse(ARGV)
|
34
|
+
|
35
|
+
listener = Percolate::Listener.new :port => $port || 10025, :ipaddr => $ip || '0.0.0.0'
|
36
|
+
listener.go
|
@@ -0,0 +1,140 @@
|
|
1
|
+
require "percolate/responder"
|
2
|
+
require "socket"
|
3
|
+
|
4
|
+
module Percolate
|
5
|
+
class Listener
|
6
|
+
CRLF = "\r\n"
|
7
|
+
|
8
|
+
# The constructor for an smtp listener. This has a number of
|
9
|
+
# options you can give it, a lot of which already have defaults.
|
10
|
+
#
|
11
|
+
# :verbose_debug:: Just turns on lots of debugging output.
|
12
|
+
# :responder:: The responder class you want to use as a
|
13
|
+
# responder. This defaults to, as you might
|
14
|
+
# expect, SMTP::Responder, but really I want
|
15
|
+
# you to subclass that and write your own
|
16
|
+
# mail handling code. Its normal default
|
17
|
+
# behaviour is to act as a sort of null MTA,
|
18
|
+
# accepting and cheerfully discarding
|
19
|
+
# messages.
|
20
|
+
# :ipaddress:: The IP address you want this to listen on.
|
21
|
+
# Defaults to 0.0.0.0 (all available
|
22
|
+
# interfaces)
|
23
|
+
# :port The port to listen on. I have it default
|
24
|
+
# to 10025 rather than, as you might expect,
|
25
|
+
# 25, because 25 is a privileged port and so
|
26
|
+
# you have to be root to listen on it.
|
27
|
+
# Unless you're foolish enough to try
|
28
|
+
# building a real MTA on this (just leave
|
29
|
+
# that kind of foolishness to me), just stick
|
30
|
+
# to leaving this at a high port and letting
|
31
|
+
# Postfix or Sendmail or your real MTA of
|
32
|
+
# choice filter through it.
|
33
|
+
def initialize(opts = {})
|
34
|
+
@ipaddress = "0.0.0.0"
|
35
|
+
@hostname = "localhost"
|
36
|
+
@port = 10025
|
37
|
+
@responder = Responder
|
38
|
+
|
39
|
+
@verbose_debug = opts[:debug]
|
40
|
+
@ipaddress = opts[:ipaddr] if opts[:ipaddr]
|
41
|
+
@port = opts[:port] if opts[:port]
|
42
|
+
@hostname = opts[:hostname] if opts[:hostname]
|
43
|
+
@responder = opts[:responder] if opts[:responder]
|
44
|
+
|
45
|
+
@socket = TCPServer.new @ipaddress, @port
|
46
|
+
end
|
47
|
+
|
48
|
+
# My current hostname as return by the HELO and EHLO commands
|
49
|
+
attr_accessor :hostname
|
50
|
+
|
51
|
+
# Once the listener is running, let it start handling mail by
|
52
|
+
# invoking the poorly-named "go" method.
|
53
|
+
def go
|
54
|
+
trap 'CLD' do
|
55
|
+
debug "Got SIGCHLD"
|
56
|
+
reap_children
|
57
|
+
end
|
58
|
+
trap 'INT' do
|
59
|
+
debug "Got SIGINT"
|
60
|
+
cleanup_and_exit
|
61
|
+
end
|
62
|
+
|
63
|
+
@pids = []
|
64
|
+
while mailsocket=@socket.accept
|
65
|
+
debug "Got connection from #{mailsocket.peeraddr[3]}"
|
66
|
+
pid = handle_connection mailsocket
|
67
|
+
mailsocket.close
|
68
|
+
@pids << pid
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def handle_connection mailsocket
|
75
|
+
fork do # I can't imagine the contortions required
|
76
|
+
# in Win32 to get "fork" to work, but hey,
|
77
|
+
# maybe someone did so anyway.
|
78
|
+
responder = @responder.new hostname,
|
79
|
+
:originating_ip => mailsocket.peeraddr[3]
|
80
|
+
begin
|
81
|
+
while true
|
82
|
+
response = responder.response
|
83
|
+
handle_response mailsocket, response
|
84
|
+
|
85
|
+
cmd = mailsocket.readline
|
86
|
+
cmd.chomp! CRLF
|
87
|
+
responder.command cmd
|
88
|
+
end
|
89
|
+
rescue TransactionFinishedException
|
90
|
+
mailsocket.puts responder.response + CRLF
|
91
|
+
mailsocket.close
|
92
|
+
exit!
|
93
|
+
rescue
|
94
|
+
mailsocket.puts "421 Server confused, shutting down" +
|
95
|
+
CRLF
|
96
|
+
mailsocket.close
|
97
|
+
exit!
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def handle_response mailsocket, response
|
103
|
+
case response
|
104
|
+
when String then
|
105
|
+
mailsocket.write response + CRLF
|
106
|
+
when Array then
|
107
|
+
response.each do |str|
|
108
|
+
mailsocket.write str + CRLF
|
109
|
+
end
|
110
|
+
when NilClass then
|
111
|
+
nil # server has nothing to say
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Prevent a BRAAAAAAINS situation
|
116
|
+
def reap_children
|
117
|
+
begin
|
118
|
+
while reaped=Process.waitpid
|
119
|
+
@pids -= [ reaped ]
|
120
|
+
end
|
121
|
+
rescue Errno::ECHILD
|
122
|
+
nil
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def cleanup_and_exit
|
127
|
+
debug "Shutting down"
|
128
|
+
@socket.close
|
129
|
+
exit
|
130
|
+
end
|
131
|
+
|
132
|
+
def debug debug_string
|
133
|
+
@debugging_stream ||= []
|
134
|
+
@debugging_stream << debug_string
|
135
|
+
if @verbose_debug
|
136
|
+
$stderr.puts debug_string
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# Quoth RFC 2821:
|
2
|
+
# SMTP transports a mail object. A mail object contains an envelope and
|
3
|
+
# content.
|
4
|
+
#
|
5
|
+
# It also happens to contain a couple of ancillary other bits of
|
6
|
+
# information these days, like what the source host was calling
|
7
|
+
# itself, what its actual IP address was, and the actual name of that
|
8
|
+
# IP address if the MTA is feeling particularly enthusiastic, as well
|
9
|
+
# as an SMTP ID to aid sysadmins in sludging through logs, assuming
|
10
|
+
# that any sysadmin still does that, and of course your own idea of
|
11
|
+
# what your name is.
|
12
|
+
#
|
13
|
+
# All of this ancillary information goes into the Received: header
|
14
|
+
# which--tee hee!--this thing doesn't even bother writing! Yet. No
|
15
|
+
# doubt there's an RFC probably written by one of those rabid
|
16
|
+
# antispammers that make you feel happier just lumping it with the
|
17
|
+
# spam (I'm sure you all know the type--"It comes from Asia! It must
|
18
|
+
# be spam!" Yes, but I *AM* in Asia) saying that any email message
|
19
|
+
# which doesn't have the required number of Received: headers in it
|
20
|
+
# must, by definition, be spam, and is thus safe to delete on sight.
|
21
|
+
#
|
22
|
+
# Which I suppose makes "add Received: header" part of my huge TODO
|
23
|
+
# list, but well, you know? I have a LIFE. Maybe.
|
24
|
+
#
|
25
|
+
# In most pieces of software, this entire rant would be replaced by a
|
26
|
+
# complete copy of the GPL, which is I am sure a total improvement over
|
27
|
+
# the standard corporate code preamble of 18 copyright messages
|
28
|
+
# detailing every single company which has urinated on the code (and
|
29
|
+
# what year they did so in).
|
30
|
+
|
31
|
+
module Percolate
|
32
|
+
|
33
|
+
# The SMTP::MailObject is mostly a class. It's what is produced by the
|
34
|
+
# Percolate::Responder class as a result of a complete SMTP transaction.
|
35
|
+
class MailObject
|
36
|
+
|
37
|
+
# The constructor. It takes a whole bunch of optional
|
38
|
+
# keyword-style parameters. View source for more details--I
|
39
|
+
# hope they're self- explanatory. If they're not, I need to
|
40
|
+
# come up with better names for them.
|
41
|
+
def initialize(opts = {})
|
42
|
+
@envelope_from = opts[:envelope_from]
|
43
|
+
@envelope_to = opts[:envelope_to]
|
44
|
+
@content = opts[:content]
|
45
|
+
@origin_ip = opts[:origin_ip]
|
46
|
+
@heloname = opts[:heloname]
|
47
|
+
@myhostname = opts[:myhostname]
|
48
|
+
@timestamp = Time.now
|
49
|
+
@smtp_id = ([nil]*16).map { rand(16).to_s(16) }.join.upcase
|
50
|
+
end
|
51
|
+
|
52
|
+
# You get to fiddle with these. The SMTP standard probably has
|
53
|
+
# some mumbling about this data being sacrosanct, but then
|
54
|
+
# again, it also says that "content" can contain anything at all
|
55
|
+
# and you still have to accept it (and presumably, later try to
|
56
|
+
# reconstruct it into an actual email message). So hey, have
|
57
|
+
# fun!
|
58
|
+
#
|
59
|
+
# Also, at the time of creation, the responder doesn't know
|
60
|
+
# necessarily who a message is meant for. Heck, it could be
|
61
|
+
# meant for twenty different people!
|
62
|
+
attr_accessor :envelope_from, :envelope_to, :content
|
63
|
+
|
64
|
+
# These four are read-only because I hate you. They're actually
|
65
|
+
# read-only because PRESUMABLY the guy creating an object of
|
66
|
+
# this type (namely, the responder), knows all this information
|
67
|
+
# at its own creation, let alone when it eventually gets around
|
68
|
+
# to building a MailObject object.
|
69
|
+
attr_reader :smtp_id, :heloname, :origin_ip, :myhostname
|
70
|
+
|
71
|
+
begin
|
72
|
+
require "gurgitate/mailmessage"
|
73
|
+
|
74
|
+
# Converts a SMTP::MailObject object into a Gurgitate-Mail
|
75
|
+
# MailMessage object.
|
76
|
+
def to_gurgitate_mailmessage
|
77
|
+
received = "Received: from #{@heloname} (#{@origin_ip}) " +
|
78
|
+
"by #{@myhostname} with SMTP ID #{smtp_id} " +
|
79
|
+
"for <#{@envelope_to}>; #{@timestamp.to_s}\n"
|
80
|
+
message = @content.gsub "\r",""
|
81
|
+
begin
|
82
|
+
g = Gurgitate::Mailmessage.new(received + message,
|
83
|
+
@envelope_to, @envelope_from)
|
84
|
+
rescue Gurgitate::IllegalHeader
|
85
|
+
# okay, let's MAKE a mail message (the RFC actually
|
86
|
+
# says that this is okay. It says that after DATA,
|
87
|
+
# an SMTP server should accept pretty well any old
|
88
|
+
# crap.)
|
89
|
+
message_text = received + "From: #{@envelope_from}\n" +
|
90
|
+
"To: undisclosed recipients:;\n" +
|
91
|
+
"X-Gurgitate-Error: #{$!}\n" +
|
92
|
+
"\n" +
|
93
|
+
@content
|
94
|
+
return Gurgitate::Mailmessage.new(message_text, @envelope_to,
|
95
|
+
@envelope_from)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
rescue LoadError => e
|
99
|
+
nil # and don't define to_gurgitate_mailmessage. I'm a huge
|
100
|
+
# egotist so I'm not including an rmail variant
|
101
|
+
# (besides, I thought that was abandonware! It's
|
102
|
+
# certainly done a great job of lurching back to life,
|
103
|
+
# encrusted with grave dirt, since Rails became popular.
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,347 @@
|
|
1
|
+
require "percolate/mail_object"
|
2
|
+
|
3
|
+
module Percolate
|
4
|
+
# A ResponderError exception is raised when something went horribly
|
5
|
+
# wrong for whatever reason.
|
6
|
+
#
|
7
|
+
# If you actually find one of these leaping into your lap in your
|
8
|
+
# own code, and you're not trying anything gratuitously silly, I
|
9
|
+
# want to know about it, because I have gone out of my way that
|
10
|
+
# these errors manifest themselves in an SMTPish way as error codes.
|
11
|
+
#
|
12
|
+
# If you are trying something silly though, I reserve the right to
|
13
|
+
# laugh at you. And possibly mock you on my blog. (Are you scared
|
14
|
+
# now?)
|
15
|
+
class ResponderError < Exception; end
|
16
|
+
|
17
|
+
# This is an exception that you *should* expect to receive, if you
|
18
|
+
# deal with the SMTP Responder yourself (you probably won't
|
19
|
+
# though--if you're smart, you'll just use the Listener and let it
|
20
|
+
# park itself on some port for other network services to talk to).
|
21
|
+
# All it means is that the client has just sent a "quit" message,
|
22
|
+
# and all is kosher, indicating that now would be a good time to
|
23
|
+
# clean up shop.
|
24
|
+
#
|
25
|
+
# NOTE VERY WELL THOUGH!: When you get one of these exceptions,
|
26
|
+
# there is still one response left in the pipe that you have to
|
27
|
+
# deliver to the client ("221 Pleasure doing business with you") so
|
28
|
+
# make sure you deliver that before closing the connection. It's
|
29
|
+
# only polite, after all.
|
30
|
+
class TransactionFinishedException < Exception; end
|
31
|
+
|
32
|
+
# This is the bit that actually handles the SMTP conversation with
|
33
|
+
# the client. Basically, you send it commands, and it acts on them.
|
34
|
+
# There is a small amount of Rubyish magic but that's there mainly
|
35
|
+
# because I'm lazy. And besides, if I weren't lazy, some guys on
|
36
|
+
# IRC would flame me. Haha! I kid. Greetz to RubyPanther by the
|
37
|
+
# wya. Even though I know you've never contributed anything and
|
38
|
+
# never will, at least you can live with the satisfaction that you
|
39
|
+
# don't have to deal with my evil racist ass any more.
|
40
|
+
class Responder
|
41
|
+
|
42
|
+
# Sets up the new smtp responder, with the parameter "mailhostname"
|
43
|
+
# as the SMTP hostname (as returned in the response to the SMTP
|
44
|
+
# HELO command.) Note that "mailhostname" can be anything at
|
45
|
+
# all, because I no longer believe that it's possible to
|
46
|
+
# actually figure out your own full hostname without actually
|
47
|
+
# literally being told it. This is probably excessively-cynical
|
48
|
+
# of me, but I've seen what happens when wide-eyed optimists try
|
49
|
+
# to guess hostnames, and it just isn't pretty.
|
50
|
+
#
|
51
|
+
# Let's just leave it at "mailhostname is a required parameter",
|
52
|
+
# shall we?
|
53
|
+
#
|
54
|
+
# Also, there are some interesting options you can give this.
|
55
|
+
# Well, only a couple. The first is :debug which you can set to
|
56
|
+
# true or false, which will cause it to print the SMTP
|
57
|
+
# conversation out. This is, of course, mostly only useful for
|
58
|
+
# debugging.
|
59
|
+
#
|
60
|
+
# The other option, :originating_ip, probably more interesting,
|
61
|
+
# comes from the listener--the IP address of the client that
|
62
|
+
# connected.
|
63
|
+
def initialize(mailhostname, opts={})
|
64
|
+
@verbose_debug = opts[:debug]
|
65
|
+
@originating_ip = opts[:originating_ip]
|
66
|
+
|
67
|
+
@mailhostname = mailhostname
|
68
|
+
@current_state = nil
|
69
|
+
@current_command = nil
|
70
|
+
@response = connect
|
71
|
+
@mail_object=nil
|
72
|
+
@debug_output = []
|
73
|
+
debug "\n\n"
|
74
|
+
end
|
75
|
+
|
76
|
+
# This is one of the methods you have to override in a subclass
|
77
|
+
# in order to use this class properly (unless chuckmail really
|
78
|
+
# is acceptable for you, in which case excellent! Also its
|
79
|
+
# default behaviour is to pretend to be an open relay, which
|
80
|
+
# should delight spammers until they figure out that all of
|
81
|
+
# their mail is being silently and cheerfully discarded, but
|
82
|
+
# they're spammers so they won't).
|
83
|
+
#
|
84
|
+
# Parameters:
|
85
|
+
# +message_object+:: A SMTP::MessageObject object, with envelope
|
86
|
+
# data and the message itself (which could, as
|
87
|
+
# the RFC says, be any old crap at all! Don't
|
88
|
+
# even expect an RFC2822-formatted message)
|
89
|
+
def process_message message_object
|
90
|
+
return "250 accepted, SMTP id is #{@mail_object.smtp_id}"
|
91
|
+
end
|
92
|
+
|
93
|
+
# Override this if you care about who the sender is (you
|
94
|
+
# probably do care who the sender is).
|
95
|
+
#
|
96
|
+
# Incidentally, you probably Do Not Want To Become An Open Spam
|
97
|
+
# Relay--you really should validate both senders and recipients,
|
98
|
+
# and only accept mail if:
|
99
|
+
#
|
100
|
+
# (a) the sender is local, and the recipient is remote, or
|
101
|
+
# (b) the sender is remote, and the recipient is local.
|
102
|
+
#
|
103
|
+
# The definition of "local" and "remote" are, of course, up to
|
104
|
+
# you--if you're using this to handle mail for a hundred
|
105
|
+
# domains, then all those hundred domains are local for you--but
|
106
|
+
# the idea is that you _shoud_ be picky about who your mail is
|
107
|
+
# from and to.
|
108
|
+
#
|
109
|
+
# This method takes one argument:
|
110
|
+
# +address+:: The email address you're validating
|
111
|
+
def validate_sender address
|
112
|
+
return true, "ok"
|
113
|
+
end
|
114
|
+
|
115
|
+
# Override this if you care about the recipient (which you
|
116
|
+
# should). When you get to this point, the accessor "sender"
|
117
|
+
# will work to return the sender, so that you can deal with both
|
118
|
+
# recipient and sender here.
|
119
|
+
def validate_recipient address
|
120
|
+
return true, "ok"
|
121
|
+
end
|
122
|
+
|
123
|
+
# The current message's sender, if the MAIL FROM: command has
|
124
|
+
# been processed yet. If it hasn't, then it returns nil, and
|
125
|
+
# probably isn't meaningful anyway.
|
126
|
+
def sender
|
127
|
+
if @mail_object
|
128
|
+
@mail_object.envelope_from
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Returns the response from the server. When there's no response,
|
133
|
+
# returns nil.
|
134
|
+
#
|
135
|
+
# I still haven't figured out what to do when there's
|
136
|
+
# more than one response (like in the case of an ESMTP capabilities
|
137
|
+
# list).
|
138
|
+
def response
|
139
|
+
resp = @response
|
140
|
+
@response = nil
|
141
|
+
debug "<<< #{resp}"
|
142
|
+
resp
|
143
|
+
end
|
144
|
+
|
145
|
+
# Send an SMTP command to the responder. Use this to send a
|
146
|
+
# line of input to the responder (including a single line of
|
147
|
+
# DATA).
|
148
|
+
#
|
149
|
+
# Parameters:
|
150
|
+
# +offered_command+:: The SMTP command (with end-of-line characters
|
151
|
+
# removed)
|
152
|
+
def command offered_command
|
153
|
+
debug ">>> #{offered_command}"
|
154
|
+
begin
|
155
|
+
dispatch offered_command
|
156
|
+
rescue ResponderError => error
|
157
|
+
@response = error.message
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
attr_reader :current_state
|
164
|
+
attr_reader :current_command
|
165
|
+
|
166
|
+
CommandTable = {
|
167
|
+
/^helo ([a-z0-9.-]+)$/i => :helo,
|
168
|
+
/^ehlo ([a-z0-9.-]+)$/i => :ehlo,
|
169
|
+
/^mail (.+)$/i => :mail,
|
170
|
+
/^rcpt (.+)$/i => :rcpt,
|
171
|
+
/^data$/i => :data,
|
172
|
+
/^\.$/ => :endmessage,
|
173
|
+
/^rset$/i => :rset,
|
174
|
+
/^quit$/ => :quit
|
175
|
+
}
|
176
|
+
|
177
|
+
StateTable = {
|
178
|
+
:connect => { :states => [ nil ],
|
179
|
+
:error => "This should never happen" },
|
180
|
+
:smtp_greeted_helo => { :states => [ :connect,
|
181
|
+
:smtp_greeted_helo,
|
182
|
+
:smtp_greeted_ehlo,
|
183
|
+
:smtp_mail_started,
|
184
|
+
:smtp_rcpt_received,
|
185
|
+
:message_received ],
|
186
|
+
:error => nil },
|
187
|
+
:smtp_greeted_ehlo => { :states => [ :connect ],
|
188
|
+
:error => nil },
|
189
|
+
:smtp_mail_started => { :states => [ :smtp_greeted_helo,
|
190
|
+
:smtp_greeted_ehlo,
|
191
|
+
:message_received ],
|
192
|
+
:error => "Can't say MAIL right now" },
|
193
|
+
:smtp_rcpt_received => { :states => [ :smtp_mail_started,
|
194
|
+
:smtp_rcpt_received ],
|
195
|
+
:error => "need MAIL FROM: first" },
|
196
|
+
:data => { :states => [ :smtp_rcpt_received ],
|
197
|
+
:error => "Specify sender and recipient first" },
|
198
|
+
:message_received => { :states => [ :data ],
|
199
|
+
:error => "500 command not recognized" },
|
200
|
+
:quit => { :states => [ :ANY ],
|
201
|
+
:error => nil },
|
202
|
+
}
|
203
|
+
|
204
|
+
def debug debug_string
|
205
|
+
@debugging_stream ||= []
|
206
|
+
@debugging_stream << debug_string
|
207
|
+
if @verbose_debug
|
208
|
+
$stderr.puts debug_string
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
def dispatch offered_command
|
213
|
+
if @current_state == :data
|
214
|
+
handle_message_data offered_command
|
215
|
+
return true
|
216
|
+
end
|
217
|
+
|
218
|
+
CommandTable.keys.each do |regex|
|
219
|
+
matchdata = regex.match offered_command
|
220
|
+
if matchdata then
|
221
|
+
return send(CommandTable[regex],*(matchdata.to_a[1..-1]))
|
222
|
+
end
|
223
|
+
end
|
224
|
+
raise ResponderError, "500 command not recognized"
|
225
|
+
end
|
226
|
+
|
227
|
+
def connect
|
228
|
+
validate_state :connect
|
229
|
+
respond "220 Ok"
|
230
|
+
end
|
231
|
+
|
232
|
+
# Makes sure that the commands are all happening in the right
|
233
|
+
# order (and complains if they're not)
|
234
|
+
def validate_state target_state
|
235
|
+
unless StateTable[target_state][:states].member? current_state or
|
236
|
+
StateTable[target_state][:states] == [ :ANY ]
|
237
|
+
raise ResponderError, "503 #{StateTable[target_state][:error]}"
|
238
|
+
end
|
239
|
+
@target_state = target_state
|
240
|
+
end
|
241
|
+
|
242
|
+
def respond response
|
243
|
+
@current_state = @target_state
|
244
|
+
@response = response
|
245
|
+
end
|
246
|
+
|
247
|
+
# The smtp commands. This is thus far not a complete set of
|
248
|
+
# every single imagineable SMTP command, but it's certainly
|
249
|
+
# enough to be able to fairly call this an "SMTP server".
|
250
|
+
#
|
251
|
+
# If you want documentation about what each of these does, see
|
252
|
+
# RFC2821, which explains it much better and in far greater
|
253
|
+
# detail than I could.
|
254
|
+
|
255
|
+
def helo remotehost
|
256
|
+
validate_state :smtp_greeted_helo
|
257
|
+
@heloname = remotehost
|
258
|
+
@mail_object = nil
|
259
|
+
@greeting_state = :smtp_greeted_helo # for rset
|
260
|
+
respond "250 #{@mailhostname}"
|
261
|
+
end
|
262
|
+
|
263
|
+
def ehlo remotehost
|
264
|
+
validate_state :smtp_greeted_ehlo
|
265
|
+
@heloname = remotehost
|
266
|
+
@mail_object = nil
|
267
|
+
@greeting_state = :smtp_greeted_ehlo # for rset
|
268
|
+
respond "250 #{@mailhostname}"
|
269
|
+
end
|
270
|
+
|
271
|
+
def mail sender
|
272
|
+
validate_state :smtp_mail_started
|
273
|
+
matchdata=sender.match(/^From:\<(.*)\>$/i);
|
274
|
+
unless matchdata
|
275
|
+
raise ResponderError, "501 bad MAIL FROM: parameter"
|
276
|
+
end
|
277
|
+
|
278
|
+
mail_from = matchdata[1]
|
279
|
+
|
280
|
+
validated, message = validate_sender mail_from
|
281
|
+
unless validated
|
282
|
+
raise ResponderError, "551 #{message}"
|
283
|
+
end
|
284
|
+
|
285
|
+
@mail_object = MailObject.new(:envelope_from => mail_from,
|
286
|
+
:heloname => @heloname,
|
287
|
+
:origin_ip => @originating_ip,
|
288
|
+
:myhostname => @mailhostname)
|
289
|
+
respond "250 #{message}"
|
290
|
+
end
|
291
|
+
|
292
|
+
def rcpt recipient
|
293
|
+
validate_state :smtp_rcpt_received
|
294
|
+
matchdata=recipient.match(/^To:\<(.*)\>$/i);
|
295
|
+
unless matchdata
|
296
|
+
raise ResponderError, "501 bad RCPT TO: parameter"
|
297
|
+
end
|
298
|
+
|
299
|
+
rcpt_to = matchdata[1]
|
300
|
+
|
301
|
+
@mail_object.envelope_to ||= []
|
302
|
+
|
303
|
+
validated, message = validate_recipient rcpt_to
|
304
|
+
unless validated
|
305
|
+
raise ResponderError, "551 #{message}"
|
306
|
+
end
|
307
|
+
@mail_object.envelope_to << rcpt_to
|
308
|
+
respond "250 #{message}"
|
309
|
+
end
|
310
|
+
|
311
|
+
def data
|
312
|
+
validate_state :data
|
313
|
+
@mail_object.content ||= ""
|
314
|
+
respond "354 end data with <cr><lf>.<cr><lf>"
|
315
|
+
end
|
316
|
+
|
317
|
+
def rset
|
318
|
+
if @mail_object
|
319
|
+
@mail_object = nil
|
320
|
+
@target_state = @greeting_state
|
321
|
+
end
|
322
|
+
respond "250 Ok"
|
323
|
+
end
|
324
|
+
|
325
|
+
def quit
|
326
|
+
validate_state :quit
|
327
|
+
if @mail_object
|
328
|
+
@mail_object = nil
|
329
|
+
end
|
330
|
+
respond "221 Pleasure doing business with you"
|
331
|
+
raise TransactionFinishedException
|
332
|
+
end
|
333
|
+
|
334
|
+
# The special-case code for message data, which unlike other
|
335
|
+
# data, is delivered as lots and lots of lines with a sentinel.
|
336
|
+
def handle_message_data message_line
|
337
|
+
if message_line == "."
|
338
|
+
@current_state = :message_received
|
339
|
+
@response = process_message @mail_object
|
340
|
+
elsif message_line == ".."
|
341
|
+
@mail_object.content << "." + "\r\n"
|
342
|
+
else
|
343
|
+
@mail_object.content << message_line + "\r\n"
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
347
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require "percolate/listener" # which actually slurps everything else in
|
@@ -0,0 +1,220 @@
|
|
1
|
+
# Please note that this is broken right now
|
2
|
+
#
|
3
|
+
# Yes, fixing it is on my TODO list.
|
4
|
+
#
|
5
|
+
# --Dave
|
6
|
+
require "test/unit"
|
7
|
+
|
8
|
+
$:.unshift File.join(File.dirname(__FILE__),"..","lib")
|
9
|
+
|
10
|
+
$DEBUG = ARGV.include? '-d'
|
11
|
+
|
12
|
+
require "percolate-mail"
|
13
|
+
|
14
|
+
class SMTPConnection
|
15
|
+
def initialize
|
16
|
+
@socket = TCPSocket.new "localhost", 10025
|
17
|
+
end
|
18
|
+
|
19
|
+
def response
|
20
|
+
resp = ""
|
21
|
+
str = @socket.recv(1000)
|
22
|
+
resp << str
|
23
|
+
$stderr.puts "<- #{resp.inspect}" if $DEBUG
|
24
|
+
if resp.chomp("\r\n") == resp
|
25
|
+
raise "whoops, we're not doing EOLs properly"
|
26
|
+
else
|
27
|
+
return resp.chomp("\r\n")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def command str
|
32
|
+
$stderr.puts "-> #{str.inspect}" if $DEBUG
|
33
|
+
@socket.write_nonblock str + "\r\n"
|
34
|
+
end
|
35
|
+
|
36
|
+
def closed?
|
37
|
+
@socket.eof?
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
class TestPercolateResponder < Test::Unit::TestCase
|
42
|
+
TestHostName="testhelohost"
|
43
|
+
|
44
|
+
def setup
|
45
|
+
@pid = fork do
|
46
|
+
listener = Percolate::Listener.new :hostname => TestHostName
|
47
|
+
listener.go
|
48
|
+
end
|
49
|
+
|
50
|
+
sleep 0.2 # to give the listener time to fire up
|
51
|
+
|
52
|
+
@responder ||= SMTPConnection.new
|
53
|
+
# @responder = Percolate::Responder.new TestHostName, :debug => false
|
54
|
+
end
|
55
|
+
|
56
|
+
def teardown
|
57
|
+
begin
|
58
|
+
if @responder
|
59
|
+
@responder.command 'quit'
|
60
|
+
@responder.response
|
61
|
+
end
|
62
|
+
rescue
|
63
|
+
end
|
64
|
+
$stderr.puts "== killing #{@pid}" if $DEBUG
|
65
|
+
Process.kill 'KILL', @pid
|
66
|
+
$stderr.puts "== waiting for #{@pid}" if $DEBUG
|
67
|
+
Process.waitpid @pid
|
68
|
+
sleep 0.1
|
69
|
+
end
|
70
|
+
|
71
|
+
def test_initialize
|
72
|
+
assert_equal "220 Ok", @responder.response
|
73
|
+
end
|
74
|
+
|
75
|
+
def test_helo
|
76
|
+
test_initialize
|
77
|
+
@responder.command "helo testhelohost"
|
78
|
+
assert_equal "250 #{TestHostName}", @responder.response
|
79
|
+
end
|
80
|
+
|
81
|
+
def test_ehlo
|
82
|
+
test_initialize
|
83
|
+
@responder.command "ehlo testhelohost"
|
84
|
+
assert_equal "250 #{TestHostName}", @responder.response
|
85
|
+
end
|
86
|
+
|
87
|
+
def test_randomcrap
|
88
|
+
test_initialize
|
89
|
+
@responder.command "huaglhuaglhuaglhuagl"
|
90
|
+
assert_equal "500 command not recognized", @responder.response
|
91
|
+
end
|
92
|
+
|
93
|
+
def test_mail_from_valid
|
94
|
+
test_ehlo
|
95
|
+
@responder.command "mail from:<validaddress>"
|
96
|
+
assert_equal "250 ok", @responder.response
|
97
|
+
end
|
98
|
+
|
99
|
+
def test_mail_from_nested
|
100
|
+
test_mail_from_valid
|
101
|
+
@responder.command "mail from:<anotheraddress>"
|
102
|
+
assert_equal "503 Can't say MAIL right now", @responder.response
|
103
|
+
end
|
104
|
+
|
105
|
+
def test_mail_from_invalid
|
106
|
+
test_ehlo
|
107
|
+
@responder.command "mail from: invalidsyntax"
|
108
|
+
assert_equal "501 bad MAIL FROM: parameter", @responder.response
|
109
|
+
assert_nil @responder.instance_variable_get("@mail_object")
|
110
|
+
end
|
111
|
+
|
112
|
+
def test_good_after_bad
|
113
|
+
test_mail_from_invalid
|
114
|
+
@responder.command "mail from:<validaddress>"
|
115
|
+
assert_equal "250 ok", @responder.response
|
116
|
+
end
|
117
|
+
|
118
|
+
def test_rset_after_mail_from
|
119
|
+
test_mail_from_valid
|
120
|
+
@responder.command "rset"
|
121
|
+
assert_equal "250 Ok", @responder.response
|
122
|
+
assert_nil @smtpd.instance_variable_get("@mail_object")
|
123
|
+
@responder.command "helo testhelohost"
|
124
|
+
assert_equal "250 #{TestHostName}", @responder.response
|
125
|
+
@responder.command "mail from:<anotheraddress>"
|
126
|
+
assert_equal "250 ok", @responder.response
|
127
|
+
end
|
128
|
+
|
129
|
+
def test_rcpt_to_valid
|
130
|
+
test_mail_from_valid
|
131
|
+
@responder.command "rcpt to:<validrcptaddress>"
|
132
|
+
assert_equal "250 ok", @responder.response
|
133
|
+
end
|
134
|
+
|
135
|
+
def test_crappy_transaction_bad_from_good_to
|
136
|
+
test_mail_from_invalid
|
137
|
+
@responder.command "rcpt to:<validrcptaddress>"
|
138
|
+
assert_equal "503 need MAIL FROM: first",
|
139
|
+
@responder.response
|
140
|
+
assert_nil @responder.instance_variable_get("@mail_object")
|
141
|
+
end
|
142
|
+
|
143
|
+
def test_rcpt_to_multiple
|
144
|
+
test_rcpt_to_valid
|
145
|
+
@responder.command "rcpt to:<anothervalidrcptaddress>"
|
146
|
+
assert_equal "250 ok", @responder.response
|
147
|
+
end
|
148
|
+
|
149
|
+
def test_rcpt_to_invalid
|
150
|
+
test_mail_from_valid
|
151
|
+
@responder.command "rcpt to: not actually valid"
|
152
|
+
assert_equal "501 bad RCPT TO: parameter", @responder.response
|
153
|
+
end
|
154
|
+
|
155
|
+
def test_rcpt_to_at_wrong_time
|
156
|
+
test_helo
|
157
|
+
@responder.command "rcpt to:<validrcptaddress>"
|
158
|
+
assert_equal "503 need MAIL FROM: first", @responder.response
|
159
|
+
end
|
160
|
+
|
161
|
+
def test_data
|
162
|
+
test_rcpt_to_valid
|
163
|
+
@responder.command "data"
|
164
|
+
assert_equal "354 end data with <cr><lf>.<cr><lf>",
|
165
|
+
@responder.response
|
166
|
+
@responder.command "This is a test"
|
167
|
+
@responder.command "Line 2 of the test"
|
168
|
+
@responder.command "."
|
169
|
+
assert_match /250 accepted, SMTP id is [A-Z0-9]+/, @responder.response
|
170
|
+
end
|
171
|
+
|
172
|
+
def test_data_at_wrong_time
|
173
|
+
test_mail_from_valid
|
174
|
+
@responder.command "data"
|
175
|
+
assert_equal "503 Specify sender and recipient first",
|
176
|
+
@responder.response
|
177
|
+
end
|
178
|
+
|
179
|
+
def test_quit
|
180
|
+
@responder.command 'quit'
|
181
|
+
assert_match /221 Pleasure doing business with you/,
|
182
|
+
@responder.response
|
183
|
+
assert @responder.closed?
|
184
|
+
@responder = nil
|
185
|
+
end
|
186
|
+
|
187
|
+
def test_quit_after_message
|
188
|
+
test_data
|
189
|
+
test_quit
|
190
|
+
end
|
191
|
+
|
192
|
+
def test_quit_after_helo
|
193
|
+
test_helo
|
194
|
+
test_quit
|
195
|
+
end
|
196
|
+
|
197
|
+
def test_quit_after_mail_from
|
198
|
+
test_mail_from_valid
|
199
|
+
test_quit
|
200
|
+
end
|
201
|
+
|
202
|
+
def test_more_than_one_message
|
203
|
+
test_data
|
204
|
+
@responder.command "mail from:<validaddress>"
|
205
|
+
assert_equal "250 ok", @responder.response
|
206
|
+
@responder.command "rcpt to:<validrcptaddress>"
|
207
|
+
assert_equal "250 ok", @responder.response
|
208
|
+
@responder.command "data"
|
209
|
+
assert_equal "354 end data with <cr><lf>.<cr><lf>",
|
210
|
+
@responder.response
|
211
|
+
@responder.command "This is a test"
|
212
|
+
@responder.command "Line 2 of the test"
|
213
|
+
@responder.command "."
|
214
|
+
assert_match /250 accepted, SMTP id is [A-Z0-9]+/, @responder.response
|
215
|
+
end
|
216
|
+
|
217
|
+
def test_long_complete_transaction
|
218
|
+
test_more_than_one_message
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,358 @@
|
|
1
|
+
require "test/unit"
|
2
|
+
|
3
|
+
$:.unshift File.join(File.dirname(__FILE__),"..","lib")
|
4
|
+
|
5
|
+
require "percolate/responder"
|
6
|
+
|
7
|
+
class MyResponder < Percolate::Responder
|
8
|
+
attr_writer :sender_validation, :recipient_validation
|
9
|
+
|
10
|
+
Responses = { false => "no", true => "ok" }
|
11
|
+
|
12
|
+
def initialize(hostname,opts={})
|
13
|
+
@sender_validation = true
|
14
|
+
@recipient_validation = true
|
15
|
+
super(hostname, opts)
|
16
|
+
end
|
17
|
+
|
18
|
+
def validate_sender addr
|
19
|
+
return @sender_validation, Responses[@sender_validation]
|
20
|
+
end
|
21
|
+
|
22
|
+
def validate_recipient addr
|
23
|
+
return @recipient_validation, Responses[@recipient_validation]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class TestPercolateResponder < Test::Unit::TestCase
|
28
|
+
TestHostName="testhost"
|
29
|
+
|
30
|
+
def setup
|
31
|
+
@responder = Percolate::Responder.new TestHostName, :debug => false
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_initialize
|
35
|
+
assert_equal "220 Ok", @responder.response
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_should_never_get_here
|
39
|
+
# deliberately meddle about with the internal state of the thing
|
40
|
+
# because I have no doubt that someone will at some point do just
|
41
|
+
# that.
|
42
|
+
@responder.instance_variable_set "@current_state", :data
|
43
|
+
assert_raises Percolate::ResponderError do
|
44
|
+
@responder.__send__ :connect
|
45
|
+
puts @responder.response
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def test_helo
|
50
|
+
test_initialize
|
51
|
+
@responder.command "helo testhelohost"
|
52
|
+
assert_equal "250 #{TestHostName}", @responder.response
|
53
|
+
assert_equal "testhelohost",
|
54
|
+
@responder.instance_variable_get("@heloname")
|
55
|
+
end
|
56
|
+
|
57
|
+
def test_should_never_get_here
|
58
|
+
test_helo
|
59
|
+
assert_raises Percolate::ResponderError do
|
60
|
+
@responder.__send__ "connect"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def test_ehlo
|
65
|
+
test_initialize
|
66
|
+
@responder.command "ehlo testhelohost"
|
67
|
+
assert_equal "250 #{TestHostName}", @responder.response
|
68
|
+
assert_equal "testhelohost",
|
69
|
+
@responder.instance_variable_get("@heloname")
|
70
|
+
end
|
71
|
+
|
72
|
+
def test_randomcrap
|
73
|
+
test_initialize
|
74
|
+
@responder.command "huaglhuaglhuaglhuagl"
|
75
|
+
assert_equal "500 command not recognized", @responder.response
|
76
|
+
end
|
77
|
+
|
78
|
+
def test_mail_from_valid
|
79
|
+
test_ehlo
|
80
|
+
@responder.command "mail from:<validaddress>"
|
81
|
+
assert_equal "250 ok", @responder.response
|
82
|
+
assert_not_nil @responder.instance_variable_get("@mail_object")
|
83
|
+
end
|
84
|
+
|
85
|
+
def test_mail_from_nested
|
86
|
+
test_mail_from_valid
|
87
|
+
@responder.command "mail from:<anotheraddress>"
|
88
|
+
assert_equal "503 Can't say MAIL right now", @responder.response
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_mail_from_invalid
|
92
|
+
test_ehlo
|
93
|
+
@responder.command "mail from: invalidsyntax"
|
94
|
+
assert_equal "501 bad MAIL FROM: parameter", @responder.response
|
95
|
+
assert_nil @responder.instance_variable_get("@mail_object")
|
96
|
+
end
|
97
|
+
|
98
|
+
def test_good_after_bad
|
99
|
+
test_mail_from_invalid
|
100
|
+
@responder.command "mail from:<validaddress>"
|
101
|
+
assert_equal "250 ok", @responder.response
|
102
|
+
assert_not_nil @responder.instance_variable_get("@mail_object")
|
103
|
+
end
|
104
|
+
|
105
|
+
def test_rset_after_mail_from
|
106
|
+
test_mail_from_valid
|
107
|
+
@responder.command "rset"
|
108
|
+
assert_equal "250 Ok", @responder.response
|
109
|
+
assert_nil @smtpd.instance_variable_get("@mail_object")
|
110
|
+
@responder.command "helo testhelohost"
|
111
|
+
assert_equal "250 #{TestHostName}", @responder.response
|
112
|
+
assert_equal "testhelohost",
|
113
|
+
@responder.instance_variable_get("@heloname")
|
114
|
+
@responder.command "mail from:<anotheraddress>"
|
115
|
+
assert_equal "250 ok", @responder.response
|
116
|
+
end
|
117
|
+
|
118
|
+
def test_rcpt_to_valid
|
119
|
+
test_mail_from_valid
|
120
|
+
@responder.command "rcpt to:<validrcptaddress>"
|
121
|
+
assert_equal "250 ok", @responder.response
|
122
|
+
assert_equal [ "validrcptaddress" ],
|
123
|
+
@responder.instance_variable_get("@mail_object") .
|
124
|
+
envelope_to
|
125
|
+
end
|
126
|
+
|
127
|
+
def test_crappy_transaction_bad_from_good_to
|
128
|
+
test_mail_from_invalid
|
129
|
+
@responder.command "rcpt to:<validrcptaddress>"
|
130
|
+
assert_equal "503 need MAIL FROM: first",
|
131
|
+
@responder.response
|
132
|
+
assert_nil @responder.instance_variable_get("@mail_object")
|
133
|
+
end
|
134
|
+
|
135
|
+
def test_rcpt_to_multiple
|
136
|
+
test_rcpt_to_valid
|
137
|
+
@responder.command "rcpt to:<anothervalidrcptaddress>"
|
138
|
+
assert_equal "250 ok", @responder.response
|
139
|
+
assert_equal [ "validrcptaddress", "anothervalidrcptaddress" ],
|
140
|
+
@responder.instance_variable_get("@mail_object") .
|
141
|
+
envelope_to
|
142
|
+
end
|
143
|
+
|
144
|
+
def test_rcpt_to_invalid
|
145
|
+
test_mail_from_valid
|
146
|
+
@responder.command "rcpt to: not actually valid"
|
147
|
+
assert_equal "501 bad RCPT TO: parameter", @responder.response
|
148
|
+
assert_nil @responder.instance_variable_get("@mail_object") .
|
149
|
+
envelope_to
|
150
|
+
end
|
151
|
+
|
152
|
+
def test_rcpt_to_at_wrong_time
|
153
|
+
test_helo
|
154
|
+
@responder.command "rcpt to:<validrcptaddress>"
|
155
|
+
assert_equal "503 need MAIL FROM: first", @responder.response
|
156
|
+
end
|
157
|
+
|
158
|
+
def test_data
|
159
|
+
test_rcpt_to_valid
|
160
|
+
@responder.command "data"
|
161
|
+
assert_equal "354 end data with <cr><lf>.<cr><lf>",
|
162
|
+
@responder.response
|
163
|
+
@responder.command "This is a test"
|
164
|
+
assert_equal nil, @responder.response
|
165
|
+
@responder.command "Line 2 of the test"
|
166
|
+
assert_equal nil, @responder.response
|
167
|
+
@responder.command "."
|
168
|
+
assert_match /^250 accepted, SMTP id is [0-9A-F]{16}$/, @responder.response
|
169
|
+
assert_equal "This is a test\r\nLine 2 of the test\r\n",
|
170
|
+
@responder.instance_variable_get("@mail_object").content
|
171
|
+
end
|
172
|
+
|
173
|
+
def test_data_with_dot_on_line
|
174
|
+
test_rcpt_to_valid
|
175
|
+
@responder.command "data"
|
176
|
+
assert_equal "354 end data with <cr><lf>.<cr><lf>",
|
177
|
+
@responder.response
|
178
|
+
@responder.command ".."
|
179
|
+
assert_equal nil, @responder.response
|
180
|
+
@responder.command "."
|
181
|
+
assert_match /^250 accepted, SMTP id is [0-9A-F]{16}$/, @responder.response
|
182
|
+
assert_equal ".\r\n",
|
183
|
+
@responder.instance_variable_get("@mail_object").content
|
184
|
+
end
|
185
|
+
|
186
|
+
def test_data_at_wrong_time
|
187
|
+
test_mail_from_valid
|
188
|
+
@responder.command "data"
|
189
|
+
assert_equal "503 Specify sender and recipient first",
|
190
|
+
@responder.response
|
191
|
+
end
|
192
|
+
|
193
|
+
def quit
|
194
|
+
assert_raises Percolate::TransactionFinishedException,
|
195
|
+
"This should never happen" do
|
196
|
+
@responder.command "quit"
|
197
|
+
end
|
198
|
+
assert_equal "221 Pleasure doing business with you",
|
199
|
+
@responder.response
|
200
|
+
end
|
201
|
+
|
202
|
+
def test_quit_after_message
|
203
|
+
test_data
|
204
|
+
quit
|
205
|
+
end
|
206
|
+
|
207
|
+
def test_quit_after_helo
|
208
|
+
test_helo
|
209
|
+
quit
|
210
|
+
end
|
211
|
+
|
212
|
+
def test_quit_after_mail_from
|
213
|
+
test_mail_from_valid
|
214
|
+
quit
|
215
|
+
end
|
216
|
+
|
217
|
+
def test_more_than_one_message
|
218
|
+
test_data
|
219
|
+
@responder.command "mail from:<validaddress>"
|
220
|
+
assert_equal "250 ok", @responder.response
|
221
|
+
assert_not_nil @responder.instance_variable_get("@mail_object")
|
222
|
+
@responder.command "rcpt to:<validrcptaddress>"
|
223
|
+
assert_equal "250 ok", @responder.response
|
224
|
+
assert_equal [ "validrcptaddress" ],
|
225
|
+
@responder.instance_variable_get("@mail_object") .
|
226
|
+
envelope_to
|
227
|
+
@responder.command "data"
|
228
|
+
assert_equal "354 end data with <cr><lf>.<cr><lf>",
|
229
|
+
@responder.response
|
230
|
+
@responder.command "This is a test"
|
231
|
+
assert_equal nil, @responder.response
|
232
|
+
@responder.command "Line 2 of the test"
|
233
|
+
assert_equal nil, @responder.response
|
234
|
+
@responder.command "."
|
235
|
+
assert_match /^250 accepted, SMTP id is [0-9A-F]{16}$/, @responder.response
|
236
|
+
end
|
237
|
+
|
238
|
+
def test_long_complete_transaction
|
239
|
+
test_more_than_one_message
|
240
|
+
quit
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
class TestSubclassedSMTPResponder < TestPercolateResponder
|
245
|
+
def setup
|
246
|
+
@responder = MyResponder.new TestHostName, :debug => false
|
247
|
+
end
|
248
|
+
|
249
|
+
def test_invalid_sender
|
250
|
+
@responder.sender_validation = false
|
251
|
+
|
252
|
+
test_ehlo
|
253
|
+
@responder.command "mail from:<invalidaddress>"
|
254
|
+
assert_equal "551 no", @responder.response
|
255
|
+
assert_nil @responder.instance_variable_get("@mail_object")
|
256
|
+
assert_nil @responder.sender
|
257
|
+
end
|
258
|
+
|
259
|
+
def test_valid_sender_after_invalid_sender
|
260
|
+
test_invalid_sender
|
261
|
+
|
262
|
+
@responder.sender_validation = true
|
263
|
+
|
264
|
+
@responder.command "mail from:<validaddress>"
|
265
|
+
assert_equal "250 ok", @responder.response
|
266
|
+
assert_not_nil @responder.instance_variable_get("@mail_object")
|
267
|
+
assert_not_nil @responder.sender
|
268
|
+
assert_equal "validaddress", @responder.sender
|
269
|
+
end
|
270
|
+
|
271
|
+
def test_invalid_recipient
|
272
|
+
@responder.recipient_validation = false
|
273
|
+
|
274
|
+
test_mail_from_valid
|
275
|
+
|
276
|
+
@responder.command "rcpt to:<invalidrcptaddress>"
|
277
|
+
assert_equal "551 no", @responder.response
|
278
|
+
assert_equal [ ],
|
279
|
+
@responder.instance_variable_get("@mail_object") .
|
280
|
+
envelope_to
|
281
|
+
end
|
282
|
+
|
283
|
+
def test_valid_recipient_after_invalid
|
284
|
+
test_invalid_recipient
|
285
|
+
|
286
|
+
@responder.recipient_validation = true
|
287
|
+
|
288
|
+
@responder.command "rcpt to:<validaddress>"
|
289
|
+
assert_equal "250 ok", @responder.response
|
290
|
+
assert_equal [ "validaddress" ],
|
291
|
+
@responder.instance_variable_get("@mail_object") .
|
292
|
+
envelope_to
|
293
|
+
end
|
294
|
+
|
295
|
+
def test_data_actual_message
|
296
|
+
test_rcpt_to_valid
|
297
|
+
@responder.command "data"
|
298
|
+
assert_equal "354 end data with <cr><lf>.<cr><lf>",
|
299
|
+
@responder.response
|
300
|
+
@responder.command "From: A Sender <sendera@example.org>"
|
301
|
+
assert_equal nil, @responder.response
|
302
|
+
@responder.command "To: A Receiver <receivera@example.com>"
|
303
|
+
assert_equal nil, @responder.response
|
304
|
+
@responder.command "Subject: You know, stuff"
|
305
|
+
assert_equal nil, @responder.response
|
306
|
+
@responder.command ""
|
307
|
+
assert_equal nil, @responder.response
|
308
|
+
@responder.command "42!"
|
309
|
+
assert_equal nil, @responder.response
|
310
|
+
@responder.command "."
|
311
|
+
assert_match /^250 accepted, SMTP id is [0-9A-F]{16}$/, @responder.response
|
312
|
+
end
|
313
|
+
|
314
|
+
def test_data_bogus_message
|
315
|
+
test_rcpt_to_valid
|
316
|
+
@responder.command "data"
|
317
|
+
assert_equal "354 end data with <cr><lf>.<cr><lf>",
|
318
|
+
@responder.response
|
319
|
+
@responder.command "Boxcar!"
|
320
|
+
assert_equal nil, @responder.response
|
321
|
+
@responder.command "."
|
322
|
+
assert_match /^250 accepted, SMTP id is [0-9A-F]{16}$/, @responder.response
|
323
|
+
end
|
324
|
+
|
325
|
+
if $LOADED_FEATURES.include? "gurgitate/mailmessage.rb"
|
326
|
+
|
327
|
+
def test_to_gurgitate_mailmessage
|
328
|
+
test_data_actual_message
|
329
|
+
mo = @responder.instance_variable_get("@mail_object")
|
330
|
+
gm = mo.to_gurgitate_mailmessage
|
331
|
+
assert_instance_of Gurgitate::Mailmessage, gm
|
332
|
+
assert gm.headers.match('From', /sendera@example.org/)
|
333
|
+
assert gm.headers.match('To', /receivera@example.com/)
|
334
|
+
end
|
335
|
+
|
336
|
+
def test_to_gurgitate_mailmessage_with_bogus
|
337
|
+
test_data_bogus_message
|
338
|
+
mo = @responder.instance_variable_get("@mail_object")
|
339
|
+
gm = mo.to_gurgitate_mailmessage
|
340
|
+
assert_instance_of Gurgitate::Mailmessage, gm
|
341
|
+
assert gm.headers.match('From', /validaddress/)
|
342
|
+
assert gm.headers.match('To', /undisclosed/)
|
343
|
+
assert_match /Boxcar!/, gm.body
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
class TestDebug < TestPercolateResponder
|
349
|
+
def setup
|
350
|
+
@old_stderr = $stderr
|
351
|
+
$stderr = File.open("/dev/null","w")
|
352
|
+
@responder = Percolate::Responder.new TestHostName, :debug => true
|
353
|
+
end
|
354
|
+
|
355
|
+
def teardown
|
356
|
+
$stderr = @old_stderr
|
357
|
+
end
|
358
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
|
2
|
+
require "test/unit"
|
3
|
+
|
4
|
+
$:.unshift File.join(File.dirname(__FILE__),"..","lib")
|
5
|
+
require "percolate-mail"
|
6
|
+
|
7
|
+
class Responder
|
8
|
+
def command cmd
|
9
|
+
nil
|
10
|
+
end
|
11
|
+
|
12
|
+
def response
|
13
|
+
"Boxcar!"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class SMTPConnection
|
18
|
+
def initialize
|
19
|
+
@socket = TCPSocket.new "localhost", 10025
|
20
|
+
end
|
21
|
+
|
22
|
+
def response
|
23
|
+
resp = ""
|
24
|
+
begin
|
25
|
+
str = @socket.recv_nonblock(1000)
|
26
|
+
resp << str
|
27
|
+
rescue Errno::EAGAIN
|
28
|
+
if resp.chomp "\r\n" == resp
|
29
|
+
raise Error, "whoops, we're not doing EOLs properly"
|
30
|
+
else
|
31
|
+
return resp
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def send str
|
37
|
+
@socket.write_nonblock str + "\r\n"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
|
42
|
+
class TestPercolateListener < Test::Unit::TestCase
|
43
|
+
TestHostName="localhost"
|
44
|
+
|
45
|
+
def test_startup_and_shutdown
|
46
|
+
pid = fork do
|
47
|
+
listener = Percolate::Listener.new :hostname => TestHostName,
|
48
|
+
:responder => ::Responder, :port => 10025
|
49
|
+
listener.go
|
50
|
+
end
|
51
|
+
|
52
|
+
sleep 0.1
|
53
|
+
|
54
|
+
sock = nil
|
55
|
+
|
56
|
+
assert_nothing_raised do
|
57
|
+
sock = TCPSocket.new "localhost", 10025
|
58
|
+
end
|
59
|
+
|
60
|
+
assert_nothing_raised do
|
61
|
+
sock.write "\r\n"
|
62
|
+
end
|
63
|
+
|
64
|
+
assert_raises Errno::EAGAIN do
|
65
|
+
assert_equal "Boxcar!\r\n", sock.recv_nonblock(1000)
|
66
|
+
end
|
67
|
+
|
68
|
+
sock.close
|
69
|
+
|
70
|
+
Process.kill 'INT', pid
|
71
|
+
assert_equal pid, Process.wait
|
72
|
+
|
73
|
+
assert_raises Errno::ECONNREFUSED do
|
74
|
+
TCPSocket.new "localhost", 10025
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
metadata
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
rubygems_version: 0.9.1
|
3
|
+
specification_version: 1
|
4
|
+
name: percolate-mail
|
5
|
+
version: !ruby/object:Gem::Version
|
6
|
+
version: 1.0.0
|
7
|
+
date: 2007-02-11 00:00:00 +09:00
|
8
|
+
summary: Skeleton smtp daemon for you to subclass
|
9
|
+
require_paths:
|
10
|
+
- lib
|
11
|
+
email: dagbrown@lart.ca
|
12
|
+
homepage: http://percolate-mail.rubyforge.org/
|
13
|
+
rubyforge_project: percolate-mail
|
14
|
+
description:
|
15
|
+
autorequire:
|
16
|
+
default_executable:
|
17
|
+
bindir: bin
|
18
|
+
has_rdoc: true
|
19
|
+
required_ruby_version: !ruby/object:Gem::Version::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 1.8.4
|
24
|
+
version:
|
25
|
+
platform: ruby
|
26
|
+
signing_key:
|
27
|
+
cert_chain:
|
28
|
+
post_install_message:
|
29
|
+
authors:
|
30
|
+
- Dave Brown
|
31
|
+
files:
|
32
|
+
- lib/percolate
|
33
|
+
- lib/percolate-mail.rb
|
34
|
+
- lib/percolate/responder.rb
|
35
|
+
- lib/percolate/mail_object.rb
|
36
|
+
- lib/percolate/listener.rb
|
37
|
+
- test/unittest_percolate_listener.rb
|
38
|
+
- test/test_percolate_listener.rb
|
39
|
+
- test/test_percolate_responder.rb
|
40
|
+
- examples/percolate-chuckmail
|
41
|
+
test_files: []
|
42
|
+
|
43
|
+
rdoc_options: []
|
44
|
+
|
45
|
+
extra_rdoc_files: []
|
46
|
+
|
47
|
+
executables: []
|
48
|
+
|
49
|
+
extensions: []
|
50
|
+
|
51
|
+
requirements: []
|
52
|
+
|
53
|
+
dependencies: []
|
54
|
+
|