percolate-mail 1.0.0

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