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.
@@ -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
+