percolate-mail 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
|