class Receiver

Constants

Patterns
PdkimReturnCodes

PDKIM Verify Codes

Unexpectedly

Public Class Methods

new(connection) click to toggle source
# File lib/rubymta/receiver.rb, line 36
def initialize(connection)
  @connection = connection
end

Public Instance Methods

auth_base(value) click to toggle source

This method MUST be supplemented to use AUTH – if the authentication succeeds, a “235 2.0.0 Authentication succeeded” message should be returned; otherwise a “530 5.7.8 Authentication failed” error should be returned

# File lib/rubymta/receiver.rb, line 593
def auth_base(value)
  if respond_to?(:auth)
    msg = auth(value)
    return msg if !msg.nil?
  end

  return "504 5.7.4 authentication mechanism not supported; use TLS and PLAIN"
end
connect_base() click to toggle source
# File lib/rubymta/receiver.rb, line 262
def connect_base
  if @contact.prohibited?
    # after the first denied message, we just slam the channel shut: no more nice guy
    LOG.warn(@mail[:mail_id]) {"Slammed connection shut. No more nice guy with #{@mail[:remote_ip]}"}
    raise Quit
  end

  if @contact.warning?
    # this is the first denied message
    @warning_given = true
    expires_at = @contact.violation.strftime('%Y-%m-%d %H:%M:%S %Z') # to kick it up to prohibited
    LOG.warn(@mail[:mail_id]) {"Access TEMPORARILY denied to #{@mail[:remote_ip]} (#{@mail[:remote_hostname]}) until #{expires_at}"}
    return "454 4.7.1 Access TEMPORARILY denied to #{@mail[:remote_ip]}: you may try again after #{expires_at}"
  end

  if respond_to?(:connect)
    msg = connect(value)
    return msg if !msg.nil?
  end

  # 8 bells and all is well
  @level = 1
  return "220 2.0.0 #{@mail[:local_hostname]} ESMTP RubyMTA 0.01 #{Time.new.strftime("%^a, %d %^b %Y %H:%M:%S %z")}"
end
data_base(value) click to toggle source
# File lib/rubymta/receiver.rb, line 411
def data_base(value)
  @mail[:data] = body = {}

  # make sure that there is at least 1 recipient
  count = 0;
  @mail[:rcptto].each { |rcpt| count += 1 if rcpt[:accepted] }
  @mail[:recipients] = count
  return "500 5.0.0 There must be at least 1 acceptable recipient" if count==0

  # receive the body of the mail
  body[:value] = value # this should be nil -- no argument on the DATA command
  body[:text] = lines = []
  send_text("354 Enter message, ending with \".\" on a line by itself")
  LOG.info(@mail[:mail_id]) {" -> (data)"} if LogReceiverConversation && !ShowIncomingData
  while true
    text = recv_text(ShowIncomingData)
    if text.nil? # the  client closed the channel abruptly
      @contact.violation
      break
    end
    break if text=="."
    lines << text
  end

  # hold the new headers here (insert them down below)
  new_headers = []

  # check DKIM signatures, if any
  pdkim = []
  ok, signatures = pdkim_verify_an_email(PDKIM_INPUT_NORMAL, lines)
  signatures.each do |signature|
    pdkim << (status = PdkimReturnCodes[signature[:verify_status]])
  end
  if !pdkim.empty?
    body[:pdkim] = pdkim
    LOG.info(@mail[:mail_id]) {"DKIM signatures (from last to first): #{body[:pdkim].inspect})"}
    new_headers << "DKIM-Status: #{body[:pdkim].inspect[1..-2]}" # strip off the '[]'
  end

  #Return-Path: <coco@tzarmail.com>
  new_headers << "Return-Path: <#{@mail[:mailfrom][:url]}>"

  #Delivered-To: <mike@tzarmail.com>
  new_headers << "Delivered-To: <#{@mail[:rcptto][0][:url]}>"
  @mail[:rcptto][1..-1].each do |rcpt|
    new_headers << "\t<#{rcpt[:url]}>"
  end

  #Received: from cpe-107-185-187-182.socal.res.rr.com ([::ffff:107.185.187.182])
  # by mail.tzarmail.com (RubyMTA 0.0.1) with ESMTP
  # (envelope from <coco@tzarmail.com>)
  # id 1dYwrI-0iDWWN-0y; Sat, 22 Jul 2017 16:02:24 +0000
  new_headers << "Received: from #{@mail[:remote_hostname]} ([#{@mail[:remote_ip]}])"
  new_headers << "\tby #{@mail[:local_hostname]} (RubyMTA #{VERSION}) with ESMTP"
  new_headers << "\t(envelope from <#{@mail[:mailfrom][:url]}>)"
  new_headers << "\tid #{@mail[:mail_id]}; #{@mail[:time]}"

  # insert the new headers into the message text
  new_headers.reverse.each { |hdr| @mail[:data][:text].insert(0,hdr) }

  # always add a DKIM signature which will include our headers
  if $app[:dkim] && !@mail[:dkim_added]
    ok, signed_message = pdkim_sign_an_email(PDKIM_INPUT_NORMAL, ServerName, 'key', $app[:dkim], PDKIM_CANON_SIMPLE, PDKIM_CANON_SIMPLE, @mail[:data][:text])
    if ok==PDKIM_OK
      @mail[:data][:text] = signed_message
      @mail[:dkim_added] = true
    else
      LOG.info(@mail[:mail_id]) {"Unsuccessful at signing #{mail[:id]} to #{host}"}
    end
  end

  # parse the headers for easier inspection, if any
  @mail.parse_headers
  @level = 1

  if respond_to?(:data)
    msg = data(value)
    return msg if !msg.nil?
  end

  #-------------------------------------------------------#
  #--- EMail queueing here -------------------------------#
  #-------------------------------------------------------#
  LOG.info(@mail[:mail_id]) {"#{@mail[:mail_id]} accepted with #{count} recipient#{if count>1 then 's' end}"}

  # the email appears good, queue it
  @mail[:accepted] = true
  case
  when !@mail.insert_parcels
    "500 5.0.0 #{ServerName} error: unable to save packet id=#{@mail[:mail_id]}"
  when !@mail.save_mail_into_queue_folder
    "500 5.0.0 #{ServerName} error: unable to save queue id=#{@mail[:mail_id]}"
  else
    "250 2.0.0 OK id=#{@mail[:mail_id]}"
  end
end
ehlo_base(value) click to toggle source
# File lib/rubymta/receiver.rb, line 287
def ehlo_base(value)
  @mail[:ehlo] = ehlo = {}
  ehlo[:value] = value

  # The email specs call for EHLO or HELO to be followed by a domain,
  # but this behavior can be turned off, if you want -- also, we look
  # to see if it's a real domain (well, duh! makes sense to do that)
  if EhloDomainRequired
    if value.index(".")
      ehlo[:domain] = domain = value.split(".").collect{ |item| item.strip }[-2..-1].join(".")
      ehlo[:ip] = ip = if EhloDomainVerifies then domain.dig_a else nil end
    else
      ehlo[:domain] = nil
      ehlo[:ip] = nil
    end

    return "501 5.5.1 Domain required after EHLO/HELO" \
      if ehlo.nil? || ehlo[:domain].nil?
    return "502 5.1.8 EHLO domain #{ehlo[:domain].inspect} was not found in the DNS system (maybe a fake domain?)" \
      if EhloDomainVerifies && ehlo[:ip].nil? && @mail[:local_port]==StandardMailPort
  end

  if respond_to?(:ehlo)
    msg = ehlo(value)
    return msg if !msg.nil?
  end

  text = "250-2.0.0 #{ServerName} Hello"
  text << " #{domain}" if domain
  text << " at #{ip}" if ip
  @level = 2
  return [text, "250-AUTH PLAIN", "250-STARTTLS", "250 HELP"]
end
expn_base(value) click to toggle source
# File lib/rubymta/receiver.rb, line 553
def expn_base(value)
  if respond_to?(:expn)
    msg = expn(value)
    return msg if !msg.nil?
  end

  return "252 2.5.1 Administrative prohibition"
end
help_base(value) click to toggle source
# File lib/rubymta/receiver.rb, line 562
def help_base(value)
  if respond_to?(:help)
    msg = help(value)
    return msg if !msg.nil?
  end

  return "250 2.0.0 QUIT AUTH, EHLO, EXPN, HELO, HELP, NOOP, RSET, VFRY, STARTTLS, MAIL FROM, RCPT TO, DATA"
end
mail_from_base(value) click to toggle source
# File lib/rubymta/receiver.rb, line 324
def mail_from_base(value)
  @mail[:mailfrom] = from = {}
  @mail[:rcptto] = []
  @mail[:saved] = false # enable saving
  from[:accepted] = false
  ok = psych_value(from, value)

  # these criteria MUST be met for any sender
  return "550 5.1.7 '#{from[:value]}' No proper sender (<...>) on the MAIL FROM line" if !ok

  # we check to see if this is a reasonable MAIL FROM address, or garbage
  return "550-5.1.7 local part #{from[:local_part].inspect} cannot contain", \
         "550 5.1.7 beginning or ending '.' or 2 or more '.'s in a row" \
    if from[:dot_error]
  return "550-5.1.7 #{from[:local_part].inspect} can only", \
         "550 5.1.7 contain a-z, A_Z, 0-9, and !#\$%&'*+-/?^_`{|}~.=" \
    if from[:char_error]

  LOG.info(@mail[:mail_id]) {"Receiving mail from sender #{from[:url]}"}

  # Check to see if this sender is one of ours -- how that is done is up to you --
  # You must implement 'client_lookup(url)' where url is the full email address --
  # Also, members MUST use use authenticated email on the SubmissionPort to
  # submit mail; non-members MUST use non-authenticated email on the
  # StandardMailPort to submit mail
  case @mail[:local_port]
  when StandardMailPort   # '25'--non client must come in here
    return "556 5.7.27 #{ServerTitle} members must use port #{InternalSubmitPort} or #{SubmissionPort} to send mail" \
      if from[:mailbox_id]
  when InternalSubmitPort # '465'-- client, internal use only, block external use with iptables
    return "556 5.7.27 Non #{ServerTitle} members must use port #{StandardMailPort} to send mail" \
      if !from[:mailbox_id]
  when SubmissionPort     # '587'--client, external or internal req. auth & enc
    return "556 5.7.27 Non #{ServerTitle} members must use port #{StandardMailPort} to send mail" \
      if !from[:mailbox_id]
    return "556 5.7.27 Traffic on port #{SubmissionPort} must be authenticated (i.e., #{ServerTitle} client)" \
      if !@mail[:authenticated]
    return "556 5.7.27 Traffic on port #{SubmissionPort} must be encrypted" \
      if !@mail[:encrypted]
  end

  # use the mail_from(value) method in the configuration file to add
  # more rules for filtering senders; psych_value will determine if
  # the sender is a member, if you have a 'client_lookup(url)', as mentioned above
  if respond_to?(:mail_from)
    msg = mail_from(value)
    return msg if !msg.nil?
  end

  @level = 3
  from[:accepted] = true
  return "250 2.0.0 OK"
end
noop_base(value) click to toggle source
# File lib/rubymta/receiver.rb, line 571
def noop_base(value)
  if respond_to?(:noop)
    msg = noop(value)
    return msg if !msg.nil?
  end

  return "250 2.0.0 OK"
end
psych_value(part, value) click to toggle source
# File lib/rubymta/receiver.rb, line 107
  def psych_value(part, value)
    # these get set in both MAIL FROM and RCPT TO
    part[:value] = value
    part[:accepted] = false

    # check for the special case of "... <postmaster>"
    n = value.match(/^(.*)<(.+)>$/)
    if n && n[2].downcase=="postmaster"
      m = Array.new
      m[0] = n[0]
      m[1] = n[1]
      m[2] = PostMasterName
    else
      # parse out the name (if any) and the address (required)
      m = value.match(/^(.*)<(.+@.+\..+)>$/)
      # there MUST be a sender/recipient address
      return false if m.nil?
    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

    # check the local part for validity?
#    Uppercase and lowercase English letters (a-z, A-Z)
#    Digits 0 to 9
#    Characters ! # $ % & ' * + - / = ? ^ _ ` { | } ~
#    Character . provided that it is not the first or last character,
#     and provided also that it does not appear two or more times consecutively.
    part[:dot_error] = true if (local_part[0]=='.' || local_part[-1]=='.' || local_part.index('..'))
    m = local_part.match(/^[a-zA-Z0-9\!\#\$%&'*+-\/?^_`{|}~=]+$/)
    part[:char_error] = m.nil?

    # lookup the email to see if it's one of ours
    if respond_to?(:client_lookup)
      part[:mailbox_id], part[:owner_id], part[:delivery] = client_lookup(part[:url])
    else
      part[:mailbox_id], part[:owner_id], part[:delivery] = [nil, nil, :remote]
    end

    # get the MXs, if needed and if any --
    # if we deliver to a mailbox which has an owner_id,
    # delivery will be made with LMTP and no MXs will be needed
    part[:mxs] = mxs = if part[:owner_id].nil? then domain.dig_mxs else nil end

    return true
    #---------------------------------------------------------------------------------------#
    #--- WHAT WE KNOW AFTER PSYCH
    #--- 1. if the return value is true, the value has the correct form
    #--- 2. the url is in part[:url]
    #--- 3. the part[:local_part] and part[:domain] are have values
    #--- 4. the MXs, if any, are in part[:mxs] => { preference => [ [mx,ip], ... ], ... }
    #--- 5. if it's our member, part[:mailbox_id], part[:owner_id] have values
    #---------------------------------------------------------------------------------------#
  end
quit(value) click to toggle source
# File lib/rubymta/receiver.rb, line 580
def quit(value)
  @done = true
  if (@mail[:saved].nil?) && (@contact.violations? == 0)
    LOG.warn(@mail[:mail_id]) {"Quitting before a message is finished is considered a violation"}
    @contact.violation
  end
  return "221 2.0.0 OK #{ServerName} closing connection"
end
rcpt_to_base(value) click to toggle source
# File lib/rubymta/receiver.rb, line 381
def rcpt_to_base(value)
  @mail[:rcptto] ||= []
  @mail[:rcptto] << rcpt = {}
  rcpt[:accepted] = false
  ok = psych_value(rcpt, value)

  # these criteria MUST be met for any recipient
  if !ok
    rcpt[:message] = "'#{value}' No proper recipient (<...>) on the RCPT TO line"
    LOG.info(@mail[:mail_id]) {rcpt[:message]}
    return "550 5.1.7 #{rcpt[:message]}"
  end

  # use the rcpt_to(value) method in the configuration file to add
  # more rules for filtering recipients; psych_value will determine if
  # the recipient is a member, if you have a 'client_lookup(url)', as mentioned above
  if respond_to?(:rcpt_to)
    msg = rcpt_to(value)
    return msg if !msg.nil?
  end

  @contact.allow
  @level = 4
  rcpt[:accepted] = true
  return "250 2.0.0 ACCEPTED"
end
receive(local_port, local_hostname, remote_port, remote_hostname, remote_ip) click to toggle source
# File lib/rubymta/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
  @contact = Contact::new(remote_ip)
  @mail = ItemOfMail::new
  @mail[:contact_id] = @contact[:id]
  @mail[:local_port] = local_port
  @mail[:local_hostname] = local_hostname
  @mail[:remote_port] = remote_port
  @mail[:remote_hostname] = remote_hostname
  @mail[:remote_ip] = remote_ip
  @mail[:saved] = true # prevent saving until we have at least a MAIL FROM
  LOG.info(@mail[:mail_id]) {"New item of mail opened with id '#{@mail[:mail_id]}'"}

  # start the main receiving process here
  @done = false
  @encrypted = false
  @authenticated = false
  @warning_given = false
  @mail[:encrypted] = false
  @mail[:authenticated] = nil
  send_text(connect_base)
  @level = 1
  response = "252 2.5.1 Administrative prohibition"
  begin
    begin
      break if @done
      text = recv_text
      # the  client closed the channel abruptly or we're forcing QUIT
      if (text.nil?) || @warning_given
        text = "QUIT"
        @contact.violation
      end
      # this handles an attempt to connect with HTTP
      if text.start_with?("GET")
        LOG.error(@mail[:mail_id]) {"An attempt was made to connect with a web browser"}
        @mail[:saved] = true # prevent saving
        raise Quit
      end
      # main command detect loop
      unrecognized = true
      Patterns.each do |pattern|
        break if pattern[0]>@level
        m = text.match(/^#{pattern[1].upcase}$/i)
        if m
          case
          when pattern[2]==:quit
            send_text(quit(m[1]))
          when pattern[0]>@level
            send_text("500 5.5.1 Command out of sequence")
          else
            response = send(pattern[2], m[1])
            @contact.violation if send_text(response)=='5'
          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"
        @contact.violation
        send_text(response)
      end
    end until @done

  rescue => e
    LOG.fatal(@mail[:mail_id]) {e.inspect}
    exit(1)
  end

  # print the intermediate structure into the log (for debugging)
  (LOG.info(@mail[:mail_id]) { "Received Mail:\n#{@mail.pretty_inspect}" }) if DumpMailIntoLog

ensure
  # run the mail queue queue runner now, if it's not running already
  ok = nil
  File.open(LockFilePath,"w") do |f|
    ok = f.flock( File::LOCK_NB | File::LOCK_EX )
    f.flock(File::LOCK_UN) if ok
  end
  if ok
    pid = Process::spawn("#{$app[:path]}/run_queue.rb")
    Process::detach(pid)
    LOG.info(@mail[:mail_id]) {"Spawned run_queue.rb, pwd=#{`pwd`.chomp}, path=>#{$app[:path]}, pid=>#{pid.inspect}"}
  end
end
recv_text(echo=true) click to toggle source
# File lib/rubymta/receiver.rb, line 74
def recv_text(echo=true)
  begin
    Timeout.timeout(ReceiverTimeout) do
      begin
        temp = @connection.gets
        case
        when temp.nil?
          LOG.warn(@mail[:mail_id]) {"The client abruptly closed the connection"}
          text = nil
        else
          text = temp.chomp.utf8
        end
      rescue Errno::ECONNRESET => e
        LOG.warn(@mail[:mail_id]) {"The client slammed the connection shut"}
        text = nil
      end
      LOG.info(@mail[:mail_id]) {" -> #{if text.nil? then "<eod>" else text end}"} \
        if echo && LogReceiverConversation
      puts " -> #{text.inspect}" if DisplayReceiverDialog
      return text
    end
  rescue Errno::EIO => e
    LOG.error(@mail[:mail_id]) {"#{e}#{Unexpectedly}"}
    raise Quit
  rescue Timeout::Error => e
    LOG.info(@mail[:mail_id]) {" -> <eod>"} if LogReceiverConversation
    return nil
  end
end
rset_base(value) click to toggle source
# File lib/rubymta/receiver.rb, line 511
def rset_base(value)
  if respond_to?(:rset)
    msg = rset(value)
    return msg if !msg.nil?
  end

  @level = 2 if @level>2
  @mail.delete(:mailfrom)
  @mail.delete(:rcptto)
  @mail.delete(:data)
  return "250 2.0.0 Reset OK"
end
send_text(text,echo=true) click to toggle source
# File lib/rubymta/receiver.rb, line 45
def send_text(text,echo=true)
  puts "<-  #{text.inspect}" if DisplayReceiverDialog
  begin
    case
    when text.nil?
      # do nothing
    when text.class==Array
      text.each do |line|
        @connection.write(line+CRLF)
        LOG.info(@mail[:mail_id]) {"<-  #{line}"} if echo && LogReceiverConversation
      end
      return text.last[0]
    else
      @connection.write(text+CRLF)
      LOG.info(@mail[:mail_id]) {"<-  #{text}"} if echo && LogReceiverConversation
      return text[0]
    end
  rescue Errno::EPIPE => e
    LOG.error(@mail[:mail_id]) {"#{e}#{Unexpectedly}"}
    raise Quit
  rescue Errno::EIO => e
    LOG.error(@mail[:mail_id]) {"#{e}#{Unexpectedly}"}
    raise Quit
  end
end
starttls(value) click to toggle source

These are not overrideable

# File lib/rubymta/receiver.rb, line 604
def starttls(value)
  send_text("220 2.0.0 TLS go ahead")
  LOG.info(@mail[:mail_id]) {"<-> (handshake)"} if LogReceiverConversation
  conn = @connection.deepclone # save the unencrypted connection in case of error
  begin
    @connection.accept
    @mail[:encrypted] = @encrypted = true
  rescue OpenSSL::SSL::SSLError => e
    # STARTTLS failed: restore the unencrypted connection
    LOG.error(@mail[:mail_id]) {"Error during STARTTLS: #{e}"}
    @connection = conn # restore original
    @mail[:encrypted] = @encrypted = false
    return "500 5.0.0 STARTTLS failed: #{e}"
  end
  return nil
end
timeout(value) click to toggle source
# File lib/rubymta/receiver.rb, line 621
def timeout(value)
  @done = true
  return ("500 5.7.1 #{"<mail id>"} closing connection due to inactivity--%s was NOT saved")
end
vfry_base(value) click to toggle source
# File lib/rubymta/receiver.rb, line 524
def vfry_base(value)
  # SMTP includes commands called "VRFY" and "EXPN" which do exactly what verification services offer.
  # While those two functions are technically different, they both reveal to a third party whether email
  # addresses exist in the server's userbase. Nearly every Postmaster (mail server administrator) on the
  # Internet has turned off VRFY and EXPN due to abuse by spammers trying to harvest addresses, as well
  # as a general security and privacy measure required by most network's operational policies. In fact,
  # since about 1999 or before, all mail servers are installed with those off by default. That should
  # give a clear indication to email verifiers about the opinion of Postmasters of the service they
  # intend to offer. Doing verification against systems that have disabled those functions, whether
  # successful or not, constitutes an attempted breach of the receiver's security policies and may be
  # considered a hostile act by site administrators. Sending high volumes of verification probes without
  # an attempt to actually send an email will often trigger filters or firewalls, thus invalidating the
  # data and impairing future verification accuracy.
  # -- http://www.spamhaus.org/news/article/722/on-the-dubious-merits-of-email-verification-services
  #
  # What this means for us is: if a spammer sends spam and we try to validate the sender's email
  # address, or bounce the message, and it's a SPAMHAUS or other blacklist company's trap address,
  # *WE* will be blacklisted. So we don't use VFRY or EXPN, and don't use a EHLO, MAIL FROM, RCPT TO,
  # QUIT sequence either. The takeaway here: thanks to spammers and Spamhaus, one can't verify a
  # sender's or recipient's address safely.

  if respond_to?(:vfry)
    msg = vfry(value)
    return msg if !msg.nil?
  end

  return "252 2.5.1 Administrative prohibition"
end