Class: Net::ReceiverCore

Inherits:
Object show all
Includes:
PDKIM
Defined in:
lib/net/receiver.rb

Overview

An Email receiver

Constant Summary collapse

CRLF =
"\r\n"
Patterns =
[
  [0, "[ /t]*QUIT[ /t]*", :do_quit],
  [1, "[ /t]*AUTH[ /t]*(.+)", :do_auth],
  [1, "[ /t]*EHLO(.*)", :do_ehlo],
  [1, "[ /t]*EXPN[ /t]*", :do_expn],
  [1, "[ /t]*HELO[ /t]+(.*)", :do_ehlo],
  [1, "[ /t]*HELP[ /t]*", :do_help],
  [1, "[ /t]*NOOP[ /t]*", :do_noop],
  [1, "[ /t]*RSET[ /t]*", :do_rset],
  [1, "[ /t]*VFRY[ /t]*", :do_vfry],
  [2, "[ /t]*STARTTLS[ /t]*", :do_starttls],
  [2, "[ /t]*MAIL FROM[ /t]*:[ \t]*(.+)", :do_mail_from],
  [3, "[ /t]*RCPT TO[ /t]*:[ \t]*(.+)", :do_rcpt_to],
  [4, "[ /t]*DATA[ /t]*", :do_data]
]
Kind =
{:mailfrom=>"MAIL FROM", :rcptto=>"RCPT TO"}
ReceiverTimeout =
30
LogConversation =
true
Unexpectedly =
"; probably caused by the client closing the connection unexpectedly"
DkimOutcomes =
{
  PDKIM_VERIFY_NONE=>"PDKIM_VERIFY_NONE",
  PDKIM_VERIFY_INVALID=>"PDKIM_VERIFY_INVALID",
  PDKIM_VERIFY_FAIL=>"PDKIM_VERIFY_FAIL",
  PDKIM_VERIFY_PASS=>"PDKIM_VERIFY_PASS",
  PDKIM_FAIL=>"PDKIM_FAIL",
  PDKIM_ERR_OOM=>"PDKIM_ERR_OOM",
  PDKIM_ERR_RSA_PRIVKEY=>"PDKIM_ERR_RSA_PRIVKEY",
  PDKIM_ERR_RSA_SIGNING=>"PDKIM_ERR_RSA_SIGNING",
  PDKIM_ERR_LONG_LINE=>"PDKIM_ERR_LONG_LINE",
  PDKIM_ERR_BUFFER_TOO_SMALL=>"PDKIM_ERR_BUFFER_TOO_SMALL"
}

Instance Method Summary collapse

Constructor Details

#initialize(connection, options) ⇒ ReceiverCore

Returns a new instance of ReceiverCore.



64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/net/receiver.rb', line 64

def initialize(connection, options)
  @connection = connection
  @option_list = [[:ehlo_validation_check, false], [:sender_character_check, true],
    [:recipient_character_check, true], [:sender_mx_check, true],
    [:recipient_mx_check, true],[:max_failed_msgs_per_period,3],
    [:copy_to_sysout, false]]
  @options = options
  @option_list.each do |key,value|
    @options[key] = value if !options.has_key?(key)
  end
  @enc_ind = '-'
end

Instance Method Details

#auth(value) ⇒ Object



450
451
452
# File 'lib/net/receiver.rb', line 450

def auth(value)
  return "235 2.0.0 Authentication succeeded"
end

#connect(remote_ip) ⇒ Object

these are the defaults, in case the user doesn’t override–you can override these in your Receiver class in order to add tests



434
435
436
# File 'lib/net/receiver.rb', line 434

def connect(remote_ip)
  return "220 2.0.0 ESMTP RubyMTA 0.01 #{Time.new.strftime("%^a, %d %^b %Y %H:%M:%S %z")}"
end

#data(value) ⇒ Object



486
487
488
# File 'lib/net/receiver.rb', line 486

def data(value)
  return "250 2.0.0 OK id=#{@mail[:id]}"
end

#do_auth(value) ⇒ Object



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/net/receiver.rb', line 280

def do_auth(value)
  auth_type, auth_encoded = value.split
  # auth_encoded contains both username and password
  case auth_type.upcase
  when "PLAIN"
    # get the password hash from the database
    username, ok = auth_encoded.validate_plain do |username|
      password(username)
    end
    if ok
      @mail[:authenticated] = username
      return "235 2.0.0 Authentication succeeded"
    else
      return "530 5.7.5 Authentication failed"
    end
  else
    return "504 5.7.6 authentication mechanism not supported"
  end
end

#do_connect(value) ⇒ Object



248
249
250
251
252
253
254
255
256
257
258
# File 'lib/net/receiver.rb', line 248

def do_connect(value)
  LOG.info("%06d"%Process::pid) {"New item of mail opened with id '#{@mail[:id]}'"}
  @mail[:connect] = p = {}
  p[:value] = value

  # this doesn't work with IPv4 addresses 'mapped' into IPv6, ie, ::ffff...
  p[:domain] = value.dig_ptr

  @level = 1 if ok?(msg = connect(p))
  return msg
end

#do_data(value) ⇒ Object



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/net/receiver.rb', line 360

def do_data(value)
  @mail[:data] = body = {}
  body[:accepted] = false
  # receive the body of the mail
  body[:value] = value # this should be nil -- no argument on the DATA command
  body[:headers] = headers = {}
  body[:text] = lines = []
  send_text("354 3.0.0 Enter message, ending with \".\" on a line by itself", false)
  LOG.info("%06d"%Process::pid) {" -> (email message)"} if LogConversation

  # get the headers into a hash
  while true
    text = recv_text(false)
    if text.strip.empty?
      body[:accepted] = true
      break
    end
    m = text.match(/^(.+?):.+$/)
    return "501 5.5.2 Malformed header" if m.nil?
    headers[m[1].downcase.gsub('-','_').to_sym] = text
  end

  # get the body into an array of strings
  while true
    text = recv_text(false)
    if text=="."
      body[:accepted] = true
      break
    end
    lines << text
  end

  # check the DKIM headers, if any
  ok, signatures = pdkim_verify_an_email(PDKIM_INPUT_NORMAL, @mail[:data][:text])
  signatures.each do |signature|
    @log.info("%06d"%Process::pid){"Signature for '#{signature[:domain]}': #{PdkimReturnCodes[signature[:verify_status]]}"}
    @mail[:signatures] ||= []
    @mail[:signatures] << [signature[:domain], signature[:verify_status], DkimOutcomes[signature[:verify_status]]]
  end if ok==PDKIM_OK

  # check the SPF, if any
  begin
    spf_server = SPF::Server.new
    request = SPF::Request.new(
      versions:      [1, 2],
      scope:         'mfrom',
      identity:      @mail[:mailfrom][:url],
      ip_address:    @mail[:remote_ip],
      helo_identity: @mail[:ehlo][:domain])
    @mail[:mailfrom][:spf] = spf_server.process(request).code
  rescue SPF::OptionRequiredError => e
    @log.info("%06d"%Process::pid) {"SPF check failed: #{e.to_s}"}
    @mail[:mailfrom][:spf] = :fail
  end

  # test all the RCPT TOs
  any_rcptto_accepted = false
  @mail[:rcptto].each { |p| any_rcptto_accepted = true if p[:accepted] } if @mail.has_key?(:rcptto)
  # passed thru the guantlet with no failures
  @mail[:accepted] = true \
    if @mail[:mailfrom][:accepted] &&
      any_rcptto_accepted &&
      @mail[:data][:accepted] &&
      @has_level_5_warnings==false

  msg = data(p)
  @level = 1
  return msg
end

#do_ehlo(value) ⇒ Object



260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/net/receiver.rb', line 260

def do_ehlo(value)
  @mail[:ehlo] = p = {}
  p[:value] = value
  p[:fip] = p[:rip] = nil
  p[:rip] = rip = value.dig_a # reverse IP
  p[:domain] = domain = rip.dig_ptr if rip
  p[:fip] = domain.dig_a if domain # forward IP

  return ("550 5.5.0 The domain name in EHLO does not validate") \
    if @options[:ehlo_validation_check] && (p[:rip].nil? || p[:fip].nil? || p[:rip]!=p[:fip])

  @level = 2 if ok?(msg = ehlo(p))
  return msg
end

#do_expn(value) ⇒ Object



300
301
302
303
304
# File 'lib/net/receiver.rb', line 300

def do_expn(value)
  @mail[:expn] = p = {}
  p[:value] = value
  return expn(p)
end

#do_help(value) ⇒ Object



306
307
308
# File 'lib/net/receiver.rb', line 306

def do_help(value)
  return help(value)
end

#do_mail_from(value) ⇒ Object



333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/net/receiver.rb', line 333

def do_mail_from(value)
  @mail[:mailfrom] = p = {:accepted=>false}
  @mail[:rcptto] = []
  msg = psych_value(:mailfrom, p, value)
  return (msg) if msg

  if ok?(msg = mail_from(p))
    p[:accepted] = true
    @level = 3
  end
  return msg
end

#do_noop(value) ⇒ Object



310
311
312
# File 'lib/net/receiver.rb', line 310

def do_noop(value)
  return noop(value)
end

#do_quit(value) ⇒ Object



275
276
277
278
# File 'lib/net/receiver.rb', line 275

def do_quit(value)
  @done = true if ok?(msg = quit(value))
  return msg
end

#do_rcpt_to(value) ⇒ Object



346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/net/receiver.rb', line 346

def do_rcpt_to(value)
  @mail[:rcptto] ||= []
  @mail[:rcptto] << p = {:accepted=>false}

  msg = psych_value(:rcptto, p, value)
  return (msg) if msg

  if ok?(msg = rcpt_to(p))
    p[:accepted] = true
    @level = 4
  end
  return msg
end

#do_rset(value) ⇒ Object



314
315
316
317
# File 'lib/net/receiver.rb', line 314

def do_rset(value)
  @level = 0 if ok?(msg = rset(value))
  return msg
end

#do_starttls(value) ⇒ Object



325
326
327
328
329
330
331
# File 'lib/net/receiver.rb', line 325

def do_starttls(value)
  send_text("220 2.0.0 TLS go ahead")
  @connection.accept
  @mail[:encrypted] = true
  @enc_ind = '~'
  return nil
end

#do_vfry(value) ⇒ Object



319
320
321
322
323
# File 'lib/net/receiver.rb', line 319

def do_vfry(value)
  @mail[:vfry] = p = {}
  p[:value] = value
  return vfry(p)
end

#ehlo(p) ⇒ Object



438
439
440
441
442
443
444
# File 'lib/net/receiver.rb', line 438

def ehlo(p)
  msg = ["250-2.0.0 #{p[:value]} Hello"]
  msg << "250-STARTTLS" if !@mail[:encrypted]
  msg << "250-AUTH PLAIN"
  msg << "250 HELP"
  return msg
end

#expn(value) ⇒ Object



458
459
460
# File 'lib/net/receiver.rb', line 458

def expn(value)
  return "252 2.5.1 Administrative prohibition"
end

#help(value) ⇒ Object



462
463
464
# File 'lib/net/receiver.rb', line 462

def help(value)
  return "250 2.0.0 QUIT AUTH, EHLO, EXPN, HELO, HELP, NOOP, RSET, VFRY, STARTTLS, MAIL FROM, RCPT TO, DATA"
end

#mail_from(value) ⇒ Object



478
479
480
# File 'lib/net/receiver.rb', line 478

def mail_from(value)
  return "250 2.0.0 OK"
end

#noop(value) ⇒ Object



466
467
468
# File 'lib/net/receiver.rb', line 466

def noop(value)
  return "250 2.0.0 OK"
end

#ok?(msg) ⇒ Boolean

these methods provide all the basic processing that needs to be done regardless of any additional checks that you make want to make

Returns:

  • (Boolean)


244
245
246
# File 'lib/net/receiver.rb', line 244

def ok?(msg)
  msg[0]!='4' && msg[0]!='5'
end

#password(username) ⇒ Object



454
455
456
# File 'lib/net/receiver.rb', line 454

def password(username)
  return nil
end

#psych_value(kind, part, value) ⇒ Object

parse the email address and investigate it



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/net/receiver.rb', line 112

def psych_value(kind, part, value)
  # the value gets set in both MAIL FROM and RCPT TO
  part[:value] = value

  # check for a bounce message
  case
  when (kind==:mailfrom) & (m = value.match(/^(.*)<>$/))
    # it's a bounce message
    part[:name] = m[1].strip
    part[:url] = "<>"
    return nil
  when (m = value.match(/^(.*)<(.+@.+\..+)>$/)).nil?
    # there MUST be a sender/recipient address
    return "501 5.1.7 '#{part[:value]}' No proper address (<...>) on the #{Kind[kind]} line" \
  end

  # break up the address
  part[:name] = m[1].strip
  part[:url] = url = m[2].strip

  # parse out the local-part and domain
  local_part, domain = url.split("@")
  part[:local_part] = local_part
  part[:domain] = domain

  if ((kind==:mailfrom) && (@options[:sender_character_check])) \
    || ((kind==:rcptto) && (@options[:recipient_character_check]))
    # check the local part:
    # uppercase and lowercase English letters (a-z, A-Z)
    # digits 0 to 9
    # characters ! # $ % & ' * + - / = ? ^ _ ` { | } ~
    part[:bad_characters] = local_part.match(/^[a-zA-Z0-9\!\#\$%&'*+-\/?^_`{|}~]+$/).nil?
    # check character . must not be first or last character,
    #   and must not appear two or more times consecutively
    part[:wrong_dot_usage] = !(local_part[0]=='.' || local_part[-1]=='.' || local_part.index('..')).nil?
  end

  # skip this if not needed
  if ((kind==:mailfrom) && (@options[:sender_mx_check])) \
    || ((kind==:rcptto) && (@options[:recipient_mx_check]))
    # get the ip for this domain
    part[:ip] = ip = domain.dig_a

    # get the mx record(s)
    part[:mxs] = mxs = domain.dig_mx

    # get the mx's ip records
    if mxs
      part[:ips] = ips = []
      mxs.each { |mx| ips << mx.dig_a }
    end
  end

  # email address investigation completed
  return nil
end

#quit(value) ⇒ Object



446
447
448
# File 'lib/net/receiver.rb', line 446

def quit(value)
  return "221 2.0.0 OK #{"example.com"} closing connection"
end

#rcpt_to(value) ⇒ Object



482
483
484
# File 'lib/net/receiver.rb', line 482

def rcpt_to(value)
  return "250 2.0.0 OK"
end

#receive(local_port, local_hostname, remote_port, remote_hostname, remote_ip) ⇒ Object

receive the connection



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/net/receiver.rb', line 172

def receive(local_port, local_hostname, remote_port, remote_hostname, remote_ip)
  # Start a hash to collect the information gathered from the receive process
  @mail = Net::ItemOfMail::new(local_port, local_hostname, remote_port, remote_hostname, remote_ip)
  @mail[:accepted] = false
  @mail[:prohibited] = false

  # start the main receiving process here
  @done = false
  @mail[:encrypted] = false
  @mail[:authenticated] = false
  send_text(do_connect(remote_ip))
  @level = 1
  @has_level_5_warnings = false

  begin
    break if @done
    text = recv_text
    unrecognized = true
    Patterns.each do |pattern|
      break if pattern[0]>@level
      m = text.match(/^#{pattern[1]}$/i)
      if m
        case
        when pattern[2]==:do_quit
          send_text(do_quit(m[1]))
        when @mail[:prohibited]
          send_text("450 4.7.1 Sender IP #{@mail[:remote_ip]} is temporarily prohibited from sending")
        when pattern[0]>@level
          send_text("503 5.5.1 Command out of sequence")
        else
          send_text(send(pattern[2], m[1].to_s.strip))
        end
        unrecognized = false
        break
      end
    end
    if unrecognized
      response = "500 5.5.1 Unrecognized command #{text.inspect}, incorrectly formatted command, or command out of sequence"
      send_text(response)
    end
  end until @done

rescue OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::EIO, Errno::EPIPE, Timeout::Error => e
  LOG.error("%06d"%Process::pid) {e}
  @mail[:accepted] = false

rescue Slam
  LOG.info("%06d"%Process::pid) {"Sender slammed the connection shut IP=#{@mail[:remote_ip]}"}
  @mail[:accepted] = false

rescue => e
  # this is the "rescue of last resort"... for "when sh*t happens"
  LOG.fatal("%06d"%Process::pid) {e.inspect}
  e.backtrace.each { |line| LOG.fatal("%06d"%Process::pid) {line} }
  @mail[:accepted] = false

ensure
  # the email is either "received" or not, then when the
  # return is executed, the process terminates
  status = if @mail[:accepted] then 'Received' else 'Rejected' end
  LOG.info("%06d"%Process::pid) {"#{status} mail with id '#{@mail[:id]}'"}
  received(@mail)
  # This is the end, beautiful friend
  # This is the end, my only friend
  # The end -- Jim Morrison
  return nil # terminates the process
end

#received(mail) ⇒ Object



490
491
492
# File 'lib/net/receiver.rb', line 490

def received(mail)
  # nothing here--just a placeholder
end

#recv_text(echo = true) ⇒ Object

receive text from the client



99
100
101
102
103
104
105
106
107
# File 'lib/net/receiver.rb', line 99

def recv_text(echo=true)
  Timeout.timeout(ReceiverTimeout) do
    raise Slam if (temp = @connection.gets).nil?
    text = temp.chomp
    LOG.info("%06d"%Process::pid) {" #{@enc_ind}> #{text}"} if echo && LogConversation
    puts " #{@enc_ind}> #{text.inspect}" if @options[:copy_to_sysout]
    return text
  end
end

#rset(value) ⇒ Object



470
471
472
# File 'lib/net/receiver.rb', line 470

def rset(value)
  return "250 2.0.0 Reset OK"
end

#send_text(text, echo = true) ⇒ Object

send text to the client



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/net/receiver.rb', line 80

def send_text(text,echo=true)
  if !text.nil?
    text = [ text ] if text.class==String
    text.each do |line|
      puts "<#{@enc_ind}  #{text.inspect}" if @options[:copy_to_sysout]
      @connection.write(line)
      @connection.write(CRLF)
      @has_level_5_warnings = true if line[0]=='5'
      LOG.info("%06d"%Process::pid) {"<#{@enc_ind}  #{text}"} if echo && LogConversation
      m = line.match(/^5[0-9]{2} [0-9]\.[0-9]\.[0-9] (.*)$/)
      LOG.error("%06d"%Process::pid) {m[1]} if m
    end
  end
  return nil
end

#vfry(value) ⇒ Object



474
475
476
# File 'lib/net/receiver.rb', line 474

def vfry(value)
  return "252 2.5.1 Administrative prohibition"
end