post_office 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,10 @@
1
+ PostOffice mock SMTP/POP3 server
2
+ ================================
3
+
4
+ By Rene van Lieshout <rene@bluerail.nl>
5
+
6
+ Usage:
7
+
8
+ sudo post_office
9
+
10
+ Listens on port 25 and 110. Configure your POP3 client with any username and password.
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
data/bin/post_office ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib'
3
+
4
+ require 'thread'
5
+ require 'smtp_server.rb'
6
+ require 'pop_server.rb'
7
+
8
+ begin
9
+ smtp_server = Thread.new{ SMTPServer.new }
10
+ pop_server = Thread.new{ POPServer.new }
11
+
12
+ smtp_server.join
13
+ pop_server.join
14
+ rescue Interrupt
15
+ puts "Interrupt..."
16
+ rescue Errno::EACCES
17
+ puts "I need root access to open ports 25 and 110. Please sudo #{__FILE__}"
18
+ end
@@ -0,0 +1,64 @@
1
+ require 'socket'
2
+ require 'thread'
3
+
4
+ # This class starts a generic server, accepting connections
5
+ # on options[:port]
6
+ #
7
+ # When extending this class make sure you:
8
+ #
9
+ # * def greet(client)
10
+ # * def process(client, command, full_data)
11
+ # * client.close when you're done
12
+ #
13
+ # You can respond to the client using:
14
+ #
15
+ # * respond(client, text)
16
+ #
17
+ # The command given to process equals the first word
18
+ # of full_data in upcase, e.g. QUIT or LIST
19
+ #
20
+ # It's possible for multiple clients to connect at the same
21
+ # time, so use client.object_id when storing local data
22
+
23
+ class GenericServer
24
+
25
+ def initialize(options)
26
+ @port = options[:port]
27
+ server = TCPServer.open(@port)
28
+ puts "#{self.class.to_s} listening on port #{@port}"
29
+
30
+ # Accept connections until infinity and beyond
31
+ loop do
32
+ Thread.start(server.accept) do |client|
33
+ begin
34
+ greet client
35
+
36
+ # Keep processing commands until somebody closed the connection
37
+ begin
38
+ input = client.gets
39
+
40
+ # The first word of a line should contain the command
41
+ command = input.to_s.gsub(/ .*/,"").upcase.gsub(/[\r\n]/,"")
42
+
43
+ puts "#{client.object_id}:#{@port} < #{input}"
44
+
45
+ process(client, command, input)
46
+ end until client.closed?
47
+ rescue => detail
48
+ puts "#{client.object_id}:#{@port} ! #{$!}"
49
+ client.close
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ # Respond to client by sending back text
56
+ def respond(client, text)
57
+ puts "#{client.object_id}:#{@port} > #{text}"
58
+ client.puts text
59
+ rescue
60
+ puts "#{client.object_id}:#{@port} ! #{$!}"
61
+ client.close
62
+ end
63
+
64
+ end
data/lib/pop_server.rb ADDED
@@ -0,0 +1,182 @@
1
+ require 'generic_server.rb'
2
+ require 'store.rb'
3
+ require 'digest/md5'
4
+
5
+ # Basic POP server
6
+
7
+ class POPServer < GenericServer
8
+ # Create new server listening on port 110
9
+ def initialize
10
+ super(:port => 110)
11
+ end
12
+
13
+ # Send a greeting to client
14
+ def greet(client)
15
+ # truncate messages for this session
16
+ Store.instance.truncate
17
+
18
+ respond(client, true, "Hello there")
19
+ end
20
+
21
+ # Process command
22
+ def process(client, command, full_data)
23
+ case command
24
+ when "CAPA" then capa(client)
25
+ when "DELE" then dele(client, message_number(full_data))
26
+ when "LIST" then list(client, message_number(full_data))
27
+ when "NOOP" then respond(client, true, "Yup.")
28
+ when "PASS" then pass(client, full_data)
29
+ when "QUIT" then quit(client)
30
+ when "RETR" then retr(client, message_number(full_data))
31
+ when "RSET" then respond(client, true, "Resurrected.")
32
+ when "STAT" then stat(client)
33
+ when "TOP" then top(client, full_data)
34
+ when "UIDL" then uidl(client, message_number(full_data))
35
+ when "USER" then user(client, full_data)
36
+ else respond(client, false, "Invalid command.")
37
+ end
38
+ end
39
+
40
+ # Show the client what we can do
41
+ def capa(client)
42
+ respond(client, true, "Here's what I can do:\r\n" +
43
+ "USER\r\n" +
44
+ "IMPLEMENTATION Bluerail Post Office POP3 Server\r\n" +
45
+ ".")
46
+ end
47
+
48
+ # Accepts username
49
+ def user(client, full_data)
50
+ respond(client, true, "Password required.")
51
+ end
52
+
53
+ # Authenticates client
54
+ def pass(client, full_data)
55
+ respond(client, true, "Logged in.")
56
+ end
57
+
58
+ # Shows list of messages
59
+ #
60
+ # When a message id is specified only list
61
+ # the size of that message
62
+ def list(client, message)
63
+ if message == :invalid
64
+ respond(client, false, "Invalid message number.")
65
+ elsif message == :all
66
+ messages = ""
67
+ Store.instance.get.each.with_index do |message, index|
68
+ messages << "#{index + 1} #{message.size}\r\n"
69
+ end
70
+ respond(client, true, "POP3 clients that break here, they violate STD53.\r\n#{messages}.")
71
+ else
72
+ message_data = Store.instance.get[message - 1]
73
+ respond(client, true, "#{message} #{message_data.size}")
74
+ end
75
+ end
76
+
77
+ # Retreives message
78
+ def retr(client, message)
79
+ if message == :invalid
80
+ respond(client, false, "Invalid message number.")
81
+ elsif message == :all
82
+ respond(client, false, "Invalid message number.")
83
+ else
84
+ message_data = Store.instance.get[message - 1]
85
+ respond(client, true, "#{message_data.size} octets to follow.\r\n" + message_data + "\r\n.")
86
+ end
87
+ end
88
+
89
+ # Shows list of message uid
90
+ #
91
+ # When a message id is specified only list
92
+ # the uid of that message
93
+ def uidl(client, message)
94
+ if message == :invalid
95
+ respond(client, false, "Invalid message number.")
96
+ elsif message == :all
97
+ messages = ""
98
+ Store.instance.get.each.with_index do |message, index|
99
+ messages << "#{index + 1} #{message_uid(message)}\r\n"
100
+ end
101
+ respond(client, true, "unique-id listing follows.\r\n#{messages}.")
102
+ else
103
+ message_data = Store.instance.get[message - 1]
104
+ respond(client, true, "#{message} #{message_uid(message_data)}")
105
+ end
106
+ end
107
+
108
+ # Shows total number of messages and size
109
+ def stat(client)
110
+ messages = Store.instance.get
111
+ total_size = messages.collect{ |m| m.size }.inject(0) { |sum,x| sum+x }
112
+ respond(client, true, "#{messages.length} #{total_size}")
113
+ end
114
+
115
+ # Display headers of message
116
+ def top(client, full_data)
117
+ full_data = full_data.split(/TOP\s(\d*)/)
118
+ messagenum = full_data[1].to_i
119
+ number_of_lines = full_data[2].to_i
120
+
121
+ messages = Store.instance.get
122
+ if messages.length >= messagenum && messagenum > 0
123
+ headers = ""
124
+ line_number = -2
125
+ messages[messagenum - 1].split(/\r\n/).each do |line|
126
+ line_number = line_number + 1 if line.gsub(/\r\n/, "") == "" || line_number > -2
127
+ headers += "#{line}\r\n" if line_number < number_of_lines
128
+ end
129
+ respond(client, true, "headers follow.\r\n" + headers + "\r\n.")
130
+ else
131
+ respond(client, false, "Invalid message number.")
132
+ end
133
+ end
134
+
135
+ # Quits
136
+ def quit(client)
137
+ respond(client, true, "Better luck next time.")
138
+ client.close
139
+ end
140
+
141
+ # Deletes message
142
+ def dele(client, message)
143
+ if message == :invalid
144
+ respond(client, false, "Invalid message number.")
145
+ elsif message == :all
146
+ respond(client, false, "Invalid message number.")
147
+ else
148
+ Store.instance.remove(message - 1)
149
+ respond(client, true, "Message deleted.")
150
+ end
151
+ end
152
+
153
+ protected
154
+
155
+ # Returns message number parsed from full_data:
156
+ #
157
+ # * No message number => :all
158
+ # * Message does not exists => :invalid
159
+ # * valid message number => some fixnum
160
+ def message_number(full_data)
161
+ if /\w*\s*\d/ =~ full_data
162
+ messagenum = full_data.gsub(/\D/,"").to_i
163
+ messages = Store.instance.get
164
+ if messages.length >= messagenum && messagenum > 0
165
+ return messagenum
166
+ else
167
+ return :invalid
168
+ end
169
+ else
170
+ return :all
171
+ end
172
+ end
173
+
174
+ # Respond to client with a POP3 prefix (+OK or -ERR)
175
+ def respond(client, status, message)
176
+ super(client, "#{status ? "+OK" : "-ERR"} #{message}\r\n")
177
+ end
178
+
179
+ def message_uid(message)
180
+ Digest::MD5.hexdigest(message)
181
+ end
182
+ end
@@ -0,0 +1,134 @@
1
+ require 'generic_server.rb'
2
+ require 'store.rb'
3
+
4
+ # Basic SMTP server
5
+
6
+ class SMTPServer < GenericServer
7
+ attr_accessor :client_data
8
+
9
+ # Create new server listening on port 25
10
+ def initialize
11
+ self.client_data = Hash.new
12
+ super(:port => 25)
13
+ end
14
+
15
+ # Send a greeting to client
16
+ def greet(client)
17
+ respond(client, 220)
18
+ end
19
+
20
+ # Process command
21
+ def process(client, command, full_data)
22
+ case command
23
+ when 'DATA' then data(client)
24
+ when 'HELO', 'EHLO' then respond(client, 250)
25
+ when 'MAIL' then mail_from(client, full_data)
26
+ when 'QUIT' then quit(client)
27
+ when 'RCPT' then rcpt_to(client, full_data)
28
+ when 'RSET' then rset(client)
29
+ else begin
30
+ if get_client_data(client, :sending_data)
31
+ append_data(client, full_data)
32
+ else
33
+ respond(client, 500)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ # Closes connection
40
+ def quit(client)
41
+ respond(client, 221)
42
+ client.close
43
+ end
44
+
45
+ # Stores sender address
46
+ def mail_from(client, full_data)
47
+ if /^MAIL FROM:/ =~ full_data.upcase
48
+ set_client_data(client, :from, full_data.gsub(/^MAIL FROM:/i,""))
49
+ respond(client, 250)
50
+ else
51
+ respond(client, 500)
52
+ end
53
+ end
54
+
55
+ # Stores recepient address
56
+ def rcpt_to(client, full_data)
57
+ if /^RCPT TO:/ =~ full_data.upcase
58
+ set_client_data(client, :to, full_data.gsub(/^RCPT TO:/i,""))
59
+ respond(client, 250)
60
+ else
61
+ respond(client, 500)
62
+ end
63
+ end
64
+
65
+ # Markes client sending data
66
+ def data(client)
67
+ set_client_data(client, :sending_data, true)
68
+ respond(client, 354)
69
+ end
70
+
71
+ # Resets local client store
72
+ def rset(client)
73
+ self.client_data[client.object_id] = Hash.new
74
+ end
75
+
76
+ # Adds full_data to incoming mail message
77
+ #
78
+ # We'll store the mail when full_data == "."
79
+ def append_data(client, full_data)
80
+ if full_data.gsub(/[\r\n]/,"") == "."
81
+ Store.instance.add(
82
+ get_client_data(client, :from).to_s,
83
+ get_client_data(client, :to).to_s,
84
+ get_client_data(client, :data).to_s
85
+ )
86
+ respond(client, 250)
87
+ else
88
+ set_client_data(client, :data, get_client_data(client, :data).to_s + full_data)
89
+ end
90
+ end
91
+
92
+ protected
93
+
94
+ # Store key value combination for this client
95
+ def set_client_data(client, key, value)
96
+ self.client_data[client.object_id] = Hash.new unless self.client_data.include?(client.object_id)
97
+ self.client_data[client.object_id][key] = value
98
+ end
99
+
100
+ # Retreive key from local client store
101
+ def get_client_data(client, key)
102
+ self.client_data[client.object_id][key] if self.client_data.include?(client.object_id)
103
+ end
104
+
105
+ # Respond to client using a standard SMTP response code
106
+ def respond(client, code)
107
+ super(client, "#{code} #{SMTPServer::RESPONSES[code].to_s}")
108
+ end
109
+
110
+ # Standard SMTP response codes
111
+ RESPONSES = {
112
+ 500 => "Syntax error, command unrecognized",
113
+ 501 => "Syntax error in parameters or arguments",
114
+ 502 => "Command not implemented",
115
+ 503 => "Bad sequence of commands",
116
+ 504 => "Command parameter not implemented",
117
+ 211 => "System status, or system help respond",
118
+ 214 => "Help message",
119
+ 220 => "Bluerail Post Office Service ready",
120
+ 221 => "Bluerail Post Office Service closing transmission channel",
121
+ 421 => "Bluerail Post Office Service not available,",
122
+ 250 => "Requested mail action okay, completed",
123
+ 251 => "User not local; will forward to <forward-path>",
124
+ 450 => "Requested mail action not taken: mailbox unavailable",
125
+ 550 => "Requested action not taken: mailbox unavailable",
126
+ 451 => "Requested action aborted: error in processing",
127
+ 551 => "User not local; please try <forward-path>",
128
+ 452 => "Requested action not taken: insufficient system storage",
129
+ 552 => "Requested mail action aborted: exceeded storage allocation",
130
+ 553 => "Requested action not taken: mailbox name not allowed",
131
+ 354 => "Start mail input; end with <CRLF>.<CRLF>",
132
+ 554 => "Transaction failed"
133
+ }.freeze
134
+ end
data/lib/store.rb ADDED
@@ -0,0 +1,33 @@
1
+ require 'fileutils'
2
+ require 'singleton'
3
+
4
+ # Message storage
5
+
6
+ class Store
7
+ include Singleton
8
+ attr_accessor :messages
9
+
10
+ def initialize
11
+ self.messages = []
12
+ end
13
+
14
+ # Returns array of messages
15
+ def get
16
+ return messages
17
+ end
18
+
19
+ # Saves message in storage
20
+ def add(mail_from, rcpt_to, message_data)
21
+ messages.push message_data
22
+ end
23
+
24
+ # Removes message from storage
25
+ def remove(index)
26
+ self.messages[index] = nil
27
+ end
28
+
29
+ # Remove empty messages
30
+ def truncate
31
+ self.messages = self.messages.reject{ |message| message.nil? }
32
+ end
33
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: post_office
3
+ version: !ruby/object:Gem::Version
4
+ hash: 27
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 0
10
+ version: 0.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Rene van Lieshout
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-04-18 00:00:00 +02:00
19
+ default_executable: post_office
20
+ dependencies: []
21
+
22
+ description: A mock SMTP/POP3 server to aid in the development of applications with mail functionality.
23
+ email: rene@bluerail.nl
24
+ executables:
25
+ - post_office
26
+ extensions: []
27
+
28
+ extra_rdoc_files:
29
+ - README.md
30
+ files:
31
+ - README.md
32
+ - VERSION
33
+ - lib/generic_server.rb
34
+ - lib/pop_server.rb
35
+ - lib/smtp_server.rb
36
+ - lib/store.rb
37
+ - bin/post_office
38
+ has_rdoc: true
39
+ homepage: http://github.com/bluerail/post_office
40
+ licenses: []
41
+
42
+ post_install_message:
43
+ rdoc_options: []
44
+
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ none: false
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ hash: 3
53
+ segments:
54
+ - 0
55
+ version: "0"
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ hash: 3
62
+ segments:
63
+ - 0
64
+ version: "0"
65
+ requirements: []
66
+
67
+ rubyforge_project:
68
+ rubygems_version: 1.3.7
69
+ signing_key:
70
+ specification_version: 3
71
+ summary: PostOffice mock SMTP/POP3 server
72
+ test_files: []
73
+