maildiode 0.0.2

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/lib/engine.rb ADDED
@@ -0,0 +1,344 @@
1
+ # Copyright 2007-2008 Kevin B. Smith
2
+ # This file is part of MailDiode.
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License version 3, as
6
+ # published by the Free Software Foundation.
7
+
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+
17
+ module MailDiode
18
+ NOOP = 'NOOP'
19
+ HELO = 'HELO'
20
+ EHLO = 'EHLO'
21
+ QUIT = 'QUIT'
22
+ RSET = 'RSET'
23
+ VRFY = 'VRFY'
24
+ MAIL = 'MAIL'
25
+ RCPT = 'RCPT'
26
+ DATA = 'DATA'
27
+
28
+ RESULT_BYE = '221 Bye'
29
+ RESULT_OK = '250 Ok'
30
+ RESULT_UNSURE = '252'
31
+ RESULT_DATA_OK = '354'
32
+
33
+ ACCEPTED = :accepted
34
+ NO_OPINION = :no_opinion
35
+ end
36
+
37
+ module MailDiode
38
+
39
+ class FilterableData
40
+ attr_accessor :sender_ip
41
+ attr_accessor :helo
42
+ attr_accessor :sender
43
+ attr_accessor :recipient
44
+ end
45
+
46
+ class Filter
47
+ def process(filterable_data)
48
+ filterable_data
49
+ end
50
+ end
51
+
52
+ class MailHandler
53
+ def valid_recipient?(recipient)
54
+ false
55
+ end
56
+
57
+ def process_message(sender, recipient, message)
58
+ nil
59
+ end
60
+ end
61
+
62
+
63
+ class Envelope
64
+ def initialize(from_address)
65
+ @from = from_address
66
+ @to = []
67
+ end
68
+
69
+ def add_recipient(to_address)
70
+ @to << to_address
71
+ end
72
+
73
+ def sender
74
+ @from
75
+ end
76
+
77
+ def recipients
78
+ @to
79
+ end
80
+ end
81
+
82
+ # State machine that handles incoming SMTP commands
83
+ class Engine
84
+ def initialize(hostname)
85
+ @hostname = hostname
86
+ @greeting = "220 #{hostname} ESMTP"
87
+ @helo_response = "250 #{hostname}"
88
+ @filters = []
89
+ end
90
+
91
+ def set_mail_handler(handler)
92
+ @mail_handler = handler
93
+ end
94
+
95
+ def add_filter(filter)
96
+ @filters << filter
97
+ end
98
+
99
+ def start(sender_ip)
100
+ @sender_ip = sender_ip
101
+ return @greeting
102
+ end
103
+
104
+ def process_line(line)
105
+ begin
106
+ if @message_text
107
+ return do_message_text(line)
108
+ end
109
+
110
+ command, args = extract_args(line)
111
+ result = process_command(command, args)
112
+ MailDiode::log_success(command, args, result)
113
+ return result
114
+ rescue SMTPError => error
115
+ MailDiode.log_info(error.text)
116
+ return error.text
117
+ end
118
+
119
+ end
120
+
121
+ def terminate?
122
+ return @should_terminate
123
+ end
124
+
125
+ def process_command(command, args)
126
+ case command.upcase
127
+ when NOOP then return do_noop(args)
128
+ when QUIT then return do_quit(args)
129
+ when HELO then return do_helo(args)
130
+ when EHLO then return do_helo(args)
131
+ when VRFY then return do_vrfy(args)
132
+ when RSET then return do_rset(args)
133
+ when MAIL then return do_mail(args)
134
+ when RCPT then return do_rcpt(args)
135
+ when DATA then return do_data(args)
136
+ end
137
+
138
+ raise_smtp_error(SMTPError::BAD_COMMAND + ': ' + command)
139
+ end
140
+
141
+ def do_noop(args)
142
+ enforce_no_args(args, SMTPError::SYNTAX_NOOP)
143
+ return RESULT_OK
144
+ end
145
+
146
+ def do_quit(args)
147
+ enforce_no_args(args, SMTPError::SYNTAX_QUIT)
148
+ @should_terminate = true
149
+ return RESULT_BYE
150
+ end
151
+
152
+ def do_helo(args)
153
+ enforce_host_arg(args, SMTPError::SYNTAX_HELO)
154
+ @helo = args
155
+ @filters.each do | filter |
156
+ if filter.respond_to? :helo
157
+ filter.helo @helo
158
+ end
159
+ end
160
+ return @helo_response
161
+ end
162
+
163
+ def do_vrfy(args)
164
+ enforce_host_arg(args, SMTPError::SYNTAX_VRFY)
165
+ return RESULT_UNSURE
166
+ end
167
+
168
+ def do_rset(args)
169
+ enforce_no_args(args, SMTPError::SYNTAX_RSET)
170
+ @envelope = nil
171
+ return RESULT_OK
172
+ end
173
+
174
+ def do_mail(args)
175
+ from_address = enforce_address_arg(args, 'from', SMTPError::SYNTAX_MAIL)
176
+ @envelope = Envelope.new(from_address)
177
+ @filters.each do | filter |
178
+ if filter.respond_to? :mail
179
+ filter.mail from_address
180
+ end
181
+ end
182
+ return RESULT_OK
183
+ end
184
+
185
+ def do_rcpt(args)
186
+ if(! @envelope)
187
+ raise_smtp_error(SMTPError::NEED_MAIL_BEFORE_RCPT)
188
+ end
189
+ if(@envelope.recipients.size >= 100)
190
+ raise_smtp_error(SMTPError::TOO_MANY_RECIPIENTS)
191
+ end
192
+ recipient = enforce_address_arg(args, 'to', SMTPError::SYNTAX_RCPT)
193
+ @filters.each do | filter |
194
+ if filter.respond_to? :rcpt
195
+ filter.rcpt recipient
196
+ end
197
+ end
198
+ filterable_data = apply_filters(@envelope.sender, recipient)
199
+ if !@mail_handler.valid_recipient?(filterable_data.recipient)
200
+ raise_smtp_error(SMTPError::UNKNOWN_RECIPIENT)
201
+ end
202
+ @envelope.add_recipient(recipient)
203
+ return RESULT_OK
204
+ end
205
+
206
+ def do_data(args)
207
+ if(! @envelope || ! @envelope.sender)
208
+ raise_smtp_error(SMTPError::NEED_RCPT_BEFORE_DATA)
209
+ end
210
+ enforce_no_args(args, SMTPError::SYNTAX_DATA)
211
+ @message_text = []
212
+ return RESULT_DATA_OK
213
+ end
214
+
215
+
216
+
217
+
218
+
219
+ def apply_filters(sender, recipient)
220
+ filterable_data = FilterableData.new
221
+ filterable_data.sender_ip = @sender_ip
222
+ filterable_data.helo = @helo
223
+ filterable_data.sender = sender
224
+ filterable_data.recipient = recipient
225
+ @filters.each do | filter |
226
+ if filter.respond_to? :process
227
+ filter.process(filterable_data)
228
+ end
229
+ end
230
+ return filterable_data
231
+ end
232
+
233
+ def do_message_text(line)
234
+ if line == '.'
235
+ return terminate_message
236
+ end
237
+
238
+ if line[0..0] == '.'
239
+ line = line[1..-1]
240
+ end
241
+
242
+ @message_text << line
243
+ return nil
244
+ end
245
+
246
+ def terminate_message
247
+ sender = @envelope.sender
248
+ recipients = @envelope.recipients
249
+ text = @message_text
250
+ @envelope = nil
251
+ @message_text = nil
252
+
253
+ ids = []
254
+ recipients.each do | recipient |
255
+ ids << process_message(sender, recipient, text)
256
+ end
257
+ return "#{RESULT_OK} #{ids.join(';')}"
258
+ end
259
+
260
+ def process_message(sender, recipient, text)
261
+ received = "Received: from #{@helo} (#{@sender_ip}) " +
262
+ "by #{@hostname} " +
263
+ "for <#{recipient}> " +
264
+ "at #{Time.now.to_s}"
265
+ full_text = received + NEWLINE + text.join(NEWLINE)
266
+
267
+ filterable_data = apply_filters(sender, recipient)
268
+ id = @mail_handler.process_message(filterable_data.recipient, full_text)
269
+ return id
270
+ end
271
+
272
+ def enforce_no_args(args, error_text)
273
+ if ! args.empty?
274
+ raise_smtp_error(error_text)
275
+ end
276
+ end
277
+
278
+ def enforce_host_arg(args, error_text)
279
+ args = args.split(/\s+/)
280
+ if args.size != 1
281
+ raise_smtp_error(error_text)
282
+ end
283
+ end
284
+
285
+ def enforce_address_arg(args, keyword, error_text)
286
+ re = /#{keyword}:\s*(.+)/i
287
+ match = re.match(args)
288
+ if !match
289
+ raise_smtp_error(error_text)
290
+ end
291
+ candidate = match[1].strip
292
+ strip_brackets = /<(.*)>/
293
+ match = strip_brackets.match(candidate)
294
+ if(match)
295
+ candidate = match[1]
296
+ end
297
+
298
+ return candidate
299
+ end
300
+
301
+ def extract_args(line)
302
+ command, args = line.split(nil, 2)
303
+ if command.nil?
304
+ command = ''
305
+ end
306
+ if args.nil?
307
+ args = ''
308
+ end
309
+ return command, args
310
+ end
311
+
312
+ def raise_smtp_error(text)
313
+ raise SMTPError.new(text)
314
+ end
315
+
316
+ end
317
+ end
318
+
319
+ module MailDiode
320
+
321
+ # SMTP errors (not success results)
322
+ class SMTPError < RuntimeError
323
+ attr_reader :text
324
+
325
+ def initialize(text)
326
+ @text = text
327
+ end
328
+
329
+ MESSAGE_NOT_HANDLED = "451 Failed: message not handled"
330
+ SYNTAX_NOOP = "501 Syntax: NOOP"
331
+ SYNTAX_QUIT = "501 Syntax: QUIT"
332
+ SYNTAX_HELO = "501 Syntax: HELO"
333
+ SYNTAX_RSET = "501 Syntax: RSET"
334
+ SYNTAX_VRFY = "501 Syntax: VRFY"
335
+ SYNTAX_MAIL = "501 Syntax: MAIL"
336
+ SYNTAX_RCPT = "501 Syntax: RCPT"
337
+ SYNTAX_DATA = "501 Syntax: DATA"
338
+ BAD_COMMAND = '502 Error: command not implemented'
339
+ TOO_MANY_RECIPIENTS = '552 Error: Too many recipients'
340
+ NEED_MAIL_BEFORE_RCPT = '503 Error: need MAIL before RCPT'
341
+ NEED_RCPT_BEFORE_DATA = '503 Error: need RCPT before DATA'
342
+ UNKNOWN_RECIPIENT = "550 Error: No such recipient here"
343
+ end
344
+ end
data/lib/greylist.rb ADDED
@@ -0,0 +1,137 @@
1
+ # Copyright 2007-2008 Kevin B. Smith
2
+ # This file is part of MailDiode.
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License version 3, as
6
+ # published by the Free Software Foundation.
7
+
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ require 'kirbybase'
17
+
18
+ ONE_HOUR_IN_SECONDS = 60*60
19
+
20
+ module MailDiode
21
+ class GreylistFilter < Filter
22
+ # Traditional (client IP, sender address, recipient address)
23
+ # or selective (client IP, HELO name, sender domain)?
24
+ GREYLIST_ADDED = "450 You are being put on a waiting list--please try again later"
25
+ GREYLIST_WAITING = "450 You are still on the waiting list--please try later"
26
+ GREYLIST_DELAY_IN_SECONDS = 60
27
+
28
+ def initialize(settings)
29
+ @delay_minutes = 60
30
+ load_settings(settings)
31
+ @kirby = KirbyBase.new(:local, nil, nil, '/var/local/maildiode/')
32
+ ensure_database_table_exists
33
+ end
34
+
35
+ def delay_seconds
36
+ @settings.delay_minutes * 60
37
+ end
38
+
39
+ def process(filterable_data)
40
+ ip = filterable_data.sender_ip
41
+ helo = filterable_data.helo
42
+ from = filterable_data.sender
43
+ to = filterable_data.recipient
44
+
45
+ if is_bounce_message(from)
46
+ MailDiode::log_info "Greylist allowing bounce message"
47
+ return true
48
+ end
49
+ from_user, from_domain = from.split('@')
50
+ table = get_database_table
51
+ found = table.select { | r | (r.ip == ip && r.helo == helo && r.from_domain = from_domain) }
52
+ case found.size
53
+ when 0
54
+ record = create_record(ip, helo, from_user, from_domain, to)
55
+ MailDiode::log_info "Greylist added #{key(record)}"
56
+ raise SMTPError.new(GREYLIST_ADDED)
57
+ when 1
58
+ if !check_existing_record(found[0])
59
+ raise SMTPError.new(GREYLIST_WAITING)
60
+ end
61
+ else
62
+ MailDiode::log_warning "Greylist found multiple entries for #{ip};#{helo};#{from};#{to}"
63
+ end
64
+ end
65
+
66
+ def is_bounce_message(from)
67
+ return from.empty?
68
+ end
69
+
70
+ def key(record)
71
+ return "#{record.ip};#{record.helo};#{record.from_domain}"
72
+ end
73
+
74
+ def create_record(ip, helo, from_user, from_domain, to)
75
+ recno = get_database_table.insert do |r|
76
+ r.ip = ip
77
+ r.helo = helo
78
+ r.from_user = from_user
79
+ r.from_domain = from_domain
80
+ r.to = to
81
+ r.first_seen = now
82
+ r.last_seen = now
83
+ r.approved = false
84
+ record = r
85
+ end
86
+ return get_database_table.select { | r | r.recno == recno }
87
+ end
88
+
89
+ def check_existing_record(record)
90
+ if(record.approved)
91
+ MailDiode::log_info "Greylist approved"
92
+ return true
93
+ end
94
+
95
+ table = get_database_table
96
+ delay_until = record.first_seen + delay_seconds
97
+ if now > delay_until
98
+ table.update(:approved => true) { | r | r.recno == record.recno }
99
+ MailDiode::log_info "Greylist approved #{key(record)}"
100
+ return true
101
+ end
102
+
103
+ table.update(:last_seen => now) { | r | r.recno == record.recno }
104
+ MailDiode::log_info "Greylist delaying #{key(record)} until #{Time.at(delay_until)}"
105
+ return false
106
+ end
107
+
108
+ def now
109
+ return Time.now.to_i
110
+ end
111
+
112
+ def get_database_table
113
+ return @kirby.get_table(:greylist)
114
+ end
115
+
116
+ def ensure_database_table_exists
117
+ if @kirby.table_exists?(:greylist)
118
+ return
119
+ end
120
+ MailDiode::log_info "Creating greylist database"
121
+ @kirby.create_table(:greylist,
122
+ :ip, :String,
123
+ :helo, :String,
124
+ :from_user, :String,
125
+ :from_domain, :String,
126
+ :to, :String,
127
+ :first_seen, :Integer,
128
+ :last_seen, :Integer,
129
+ :approved, :Boolean
130
+ )
131
+ end
132
+
133
+ def load_settings(settings)
134
+ @delay_minutes = settings.get_setting('greylist', 'delayminutes').to_i
135
+ end
136
+ end
137
+ end
data/lib/maildir.rb ADDED
@@ -0,0 +1,113 @@
1
+ # Copyright 2007-2008 Kevin B. Smith
2
+ # This file is part of MailDiode.
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License version 3, as
6
+ # published by the Free Software Foundation.
7
+
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ require 'gurgitate/deliver'
17
+
18
+ module MailDiode
19
+ class FolderMissingError < IOError
20
+ attr_reader :folder
21
+
22
+ def initialize(folder_name)
23
+ @folder = folder_name
24
+ end
25
+ end
26
+
27
+ class DeliverableMessage
28
+ include Gurgitate::Deliver::Maildir
29
+
30
+ def initialize(message_content)
31
+ @message = message_content
32
+ end
33
+
34
+ def save(maildir)
35
+ deliver_message(maildir)
36
+ end
37
+
38
+ def to_s
39
+ @message
40
+ end
41
+ end
42
+
43
+ class MaildirMessageHandler < MailHandler
44
+ def initialize(settings)
45
+ @maildirs = {}
46
+ load_settings(settings)
47
+ end
48
+
49
+ def valid_recipient?(recipient)
50
+ return get_maildir(recipient)
51
+ end
52
+
53
+ def process_message(recipient, full_text)
54
+ message = DeliverableMessage.new(full_text)
55
+ raw_dir = get_maildir(recipient)
56
+ folder = ''
57
+ dir = "#{raw_dir.chomp('/')}#{folder}/"
58
+ MailDiode::log_info "Delivering to #{dir}"
59
+ if !File.exist?(dir)
60
+ MailDiode::log_error "ERROR: Missing maildir folder: #{dir}"
61
+ raise FolderMissingError.new(File.basename(dir))
62
+ end
63
+ message.save(dir)
64
+ end
65
+
66
+ def get_maildir(recipient)
67
+ return @maildirs[recipient]
68
+ end
69
+ =begin
70
+ def valid_recipient?(recipient)
71
+ if (get_maildir(recipient))
72
+ return true
73
+ end
74
+ # @core.log.debug "Rejecting recipient: #{recipient}"
75
+ return false
76
+ end
77
+
78
+ def deliver_message(recipient, envelope_to, folder, raw_message_object)
79
+ received = "Received: from #{raw_message_object.heloname} " +
80
+ "(#{raw_message_object.origin_ip}) " +
81
+ "by #{raw_message_object.myhostname} " +
82
+ "with SMTP ID #{raw_message_object.smtp_id} " +
83
+ "for <#{recipient}>; " +
84
+ #"#{raw_message_object.timestamp.to_s}\n"
85
+ "#{Time.now.to_s}\n"
86
+ full_text = received + raw_message_object.content.gsub("\r","")
87
+ message = DeliverableMessage.new(full_text)
88
+ dir = "#{get_maildir(recipient).chomp('/')}#{folder}/"
89
+ # @core.log.info "Delivering for #{envelope_to} to #{dir}"
90
+ if !File.exist?(dir)
91
+ # @core.log.error "ERROR: Missing maildir folder: #{dir}"
92
+ raise FolderMissingError.new(File.basename(dir))
93
+ end
94
+ message.save(dir)
95
+
96
+ return true
97
+ end
98
+
99
+ =end
100
+
101
+ def load_settings(settings)
102
+ settings.get_settings('maildir').each do | setting |
103
+ name, maildir = setting
104
+ if(maildir.index('/') != 0)
105
+ raise "Maildir must start with slash (/): #{maildir}"
106
+ end
107
+ maildir.untaint
108
+ @maildirs[name] = maildir
109
+ end
110
+ end
111
+ end
112
+ end
113
+
data/lib/server.rb ADDED
@@ -0,0 +1,93 @@
1
+ # Copyright 2007-2008 Kevin B. Smith
2
+ # This file is part of MailDiode.
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License version 3, as
6
+ # published by the Free Software Foundation.
7
+
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ require 'gserver'
17
+ require 'engine'
18
+ require 'io/wait'
19
+
20
+ module MailDiode
21
+ class Server < GServer
22
+ def initialize(engine_factory, settings)
23
+ load_settings(settings)
24
+
25
+ @factory = engine_factory
26
+ super(@port, @ip, @max_connections)
27
+ end
28
+
29
+ def serve(client)
30
+ begin
31
+ MailDiode::log_debug("Accepting client")
32
+ MailDiode::log_info("Connections: #{connections}")
33
+ client.binmode
34
+ engine = @factory.create_engine(@hostname)
35
+ family, port, hostname, ip = client.addr
36
+ greeting = engine.start(ip)
37
+ MailDiode::log_info("Greeting: #{greeting}")
38
+ client << response(greeting)
39
+ while !engine.terminate?
40
+ line = get_line(client, TIMEOUT_SECONDS)
41
+ if(!line)
42
+ MailDiode::log_info("---client disconnected")
43
+ return
44
+ end
45
+ line.chomp!(NEWLINE)
46
+ result = engine.process_line(line)
47
+ if(result)
48
+ discard_early_input(client)
49
+ client << response(result)
50
+ end
51
+ end
52
+ rescue Errno::ECONNRESET
53
+ MailDiode::log_info "---client dropped connection"
54
+ rescue Exception => e
55
+ MailDiode::log_error("Exception #{e.class}: #{e}")
56
+ e.backtrace.each do | line |
57
+ MailDiode::log_error(line)
58
+ end
59
+ client << response("451 Internal Server Error")
60
+ end
61
+ end
62
+
63
+ def get_line(input, timeout_seconds)
64
+ line = nil
65
+ t = Thread.new { line = input.gets(NEWLINE) }
66
+ t.join(timeout_seconds)
67
+ return line
68
+ end
69
+
70
+ def discard_early_input(client)
71
+ while(client.ready?)
72
+ client.getc
73
+ end
74
+ end
75
+
76
+ def response(text)
77
+ return "#{text}#{NEWLINE}"
78
+ end
79
+
80
+ def load_settings(settings)
81
+ @hostname = settings.get_setting('server', 'hostname')
82
+ @ip = settings.get_setting('server', 'ip')
83
+ @ip.untaint
84
+ @port = settings.get_int('server', 'port', DEFAULT_PORT)
85
+ @port.untaint
86
+ @max_connections = settings.get_int('server', 'maxconnections', DEFAULT_MAX_CONNECTIONS)
87
+ end
88
+
89
+ DEFAULT_PORT = 10025
90
+ DEFAULT_MAX_CONNECTIONS = 20
91
+ TIMEOUT_SECONDS = 1200 # djb recommends 1200
92
+ end
93
+ end