module Sisimai::RFC3464

Sisimai::RFC3464 - bounce mail parser class for Fallback.

Constants

Indicators

tools.ietf.org/html/rfc3464

MarkingsOf
ReAddr
ReSkip
ReStop

Public Class Methods

description() click to toggle source
# File lib/sisimai/rfc3464.rb, line 457
def description; 'Fallback Module for MTAs'; end
make(mhead, mbody) click to toggle source

Detect an error for RFC3464 @param [Hash] mhead Message headers of a bounce email @param [String] mbody Message body of a bounce email @return [Hash] Bounce data list and message/rfc822 part @return [Nil] it failed to parse or the arguments are missing

# File lib/sisimai/rfc3464.rb, line 100
def make(mhead, mbody)
  dscontents = [Sisimai::Lhost.DELIVERYSTATUS]
  bodyslices = mbody.scrub('?').split("\n")
  readslices = ['']
  rfc822text = ''   # (String) message/rfc822 part text
  maybealias = nil  # (String) Original-Recipient Field
  blanklines = 0    # (Integer) The number of blank lines
  readcursor = 0    # (Integer) Points the current cursor position
  recipients = 0    # (Integer) The number of 'Final-Recipient' header
  itisbounce = false
  connheader = {
    'date'  => nil, # The value of Arrival-Date header
    'rhost' => nil, # The value of Reporting-MTA header
    'lhost' => nil, # The value of Received-From-MTA header
  }
  v = nil

  while e = bodyslices.shift do
    # Read error messages and delivery status lines from the head of the email
    # to the previous line of the beginning of the original message.
    readslices << e # Save the current line for the next loop
    d = e.downcase

    if readcursor == 0
      # Beginning of the bounce message or delivery status part
      if d =~ MarkingsOf[:message]
        readcursor |= Indicators[:deliverystatus]
        next
      end
    end

    if (readcursor & Indicators[:'message-rfc822']) == 0
      # Beginning of the original message part
      if d =~ MarkingsOf[:rfc822]
        readcursor |= Indicators[:'message-rfc822']
        next
      end
    end

    if readcursor & Indicators[:'message-rfc822'] > 0
      # Inside of the original message part
      if e.empty?
        blanklines += 1
        break if blanklines > 1
        next
      end
      rfc822text << e << "\n"
    else
      # Error message part
      next unless readcursor & Indicators[:deliverystatus] > 0
      next if e.empty?

      v = dscontents[-1]
      if cv = e.match(/\A(Final|Original)-[Rr]ecipient:[ ]*.+;[ ]*([^ ]+)\z/)
        # 2.3.2 Final-Recipient field
        #   The Final-Recipient field indicates the recipient for which this set
        #   of per-recipient fields applies.  This field MUST be present in each
        #   set of per-recipient data.
        #   The syntax of the field is as follows:
        #
        #       final-recipient-field =
        #           "Final-Recipient" ":" address-type ";" generic-address
        #
        # 2.3.1 Original-Recipient field
        #   The Original-Recipient field indicates the original recipient address
        #   as specified by the sender of the message for which the DSN is being
        #   issued.
        #
        #       original-recipient-field =
        #           "Original-Recipient" ":" address-type ";" generic-address
        #
        #       generic-address = *text
        if cv[1] == 'Original'
          # Original-Recipient: ...
          maybealias = cv[2]
        else
          # Final-Recipient: ...
          x = v['recipient'] || ''
          y = Sisimai::Address.s3s4(cv[2])
          y = maybealias unless Sisimai::RFC5322.is_emailaddress(y)

          if !x.empty? && x != y
            # There are multiple recipient addresses in the message body.
            dscontents << Sisimai::Lhost.DELIVERYSTATUS
            v = dscontents[-1]
          end
          v['recipient'] = y
          recipients += 1
          itisbounce ||= true

          v['alias'] ||= maybealias
          maybealias = nil
        end

      elsif cv = e.match(/\AX-Actual-Recipient:[ ]*(?:RFC|rfc)822;[ ]*([^ ]+)\z/)
        # X-Actual-Recipient: RFC822; |IFS=' ' && exec procmail -f- || exit 75 ...
        # X-Actual-Recipient: rfc822; kijitora@neko.example.jp
        v['alias'] = cv[1] unless cv[1] =~ /[ \t]+/

      elsif cv = e.match(/\AAction:[ ]*(.+)\z/)
        # 2.3.3 Action field
        #   The Action field indicates the action performed by the Reporting-MTA
        #   as a result of its attempt to deliver the message to this recipient
        #   address.  This field MUST be present for each recipient named in the
        #   DSN.
        #   The syntax for the action-field is:
        #
        #       action-field = "Action" ":" action-value
        #       action-value =
        #           "failed" / "delayed" / "delivered" / "relayed" / "expanded"
        #
        #   The action-value may be spelled in any combination of upper and lower
        #   case characters.
        v['action'] = cv[1].downcase

        # failed (bad destination mailbox address)
        if cv = v['action'].match(/\A([^ ]+)[ ]/) then v['action'] = cv[1] end

      elsif cv = e.match(/\AStatus:[ ]*(\d[.]\d+[.]\d+)/)
        # 2.3.4 Status field
        #   The per-recipient Status field contains a transport-independent
        #   status code that indicates the delivery status of the message to that
        #   recipient.  This field MUST be present for each delivery attempt
        #   which is described by a DSN.
        #
        #   The syntax of the status field is:
        #
        #       status-field = "Status" ":" status-code
        #       status-code = DIGIT "." 1*3DIGIT "." 1*3DIGIT
        v['status'] = cv[1]

      elsif cv = e.match(/\AStatus:[ ]*(\d+[ ]+.+)\z/)
        # Status: 553 Exceeded maximum inbound message size
        v['alterrors'] = cv[1]

      elsif cv = e.match(/\ARemote-MTA:[ ]*(?:DNS|dns);[ ]*(.+)\z/)
        # 2.3.5 Remote-MTA field
        #   The value associated with the Remote-MTA DSN field is a printable
        #   ASCII representation of the name of the "remote" MTA that reported
        #   delivery status to the "reporting" MTA.
        #
        #       remote-mta-field = "Remote-MTA" ":" mta-name-type ";" mta-name
        #
        #   NOTE: The Remote-MTA field preserves the "while talking to"
        #   information that was provided in some pre-existing nondelivery
        #   reports.
        #
        #   This field is optional.  It MUST NOT be included if no remote MTA was
        #   involved in the attempted delivery of the message to that recipient.
        v['rhost'] = cv[1].downcase

      elsif cv = e.match(/\ALast-Attempt-Date:[ ]*(.+)\z/)
        # 2.3.7 Last-Attempt-Date field
        #   The Last-Attempt-Date field gives the date and time of the last
        #   attempt to relay, gateway, or deliver the message (whether successful
        #   or unsuccessful) by the Reporting MTA.  This is not necessarily the
        #   same as the value of the Date field from the header of the message
        #   used to transmit this delivery status notification: In cases where
        #   the DSN was generated by a gateway, the Date field in the message
        #   header contains the time the DSN was sent by the gateway and the DSN
        #   Last-Attempt-Date field contains the time the last delivery attempt
        #   occurred.
        #
        #       last-attempt-date-field = "Last-Attempt-Date" ":" date-time
        v['date'] = cv[1]
      else
        if cv = e.match(/\ADiagnostic-Code:[ ]*(.+?);[ ]*(.+)\z/)
          # 2.3.6 Diagnostic-Code field
          #   For a "failed" or "delayed" recipient, the Diagnostic-Code DSN field
          #   contains the actual diagnostic code issued by the mail transport.
          #   Since such codes vary from one mail transport to another, the
          #   diagnostic-type sub-field is needed to specify which type of
          #   diagnostic code is represented.
          #
          #       diagnostic-code-field =
          #           "Diagnostic-Code" ":" diagnostic-type ";" *text
          v['spec'] = cv[1].upcase
          v['diagnosis'] = cv[2]

        elsif cv = e.match(/\ADiagnostic-Code:[ ]*(.+)\z/)
          # No value of "diagnostic-type"
          # Diagnostic-Code: 554 ...
          v['diagnosis'] = cv[1]

        elsif readslices[-2].start_with?('Diagnostic-Code:') && cv = e.match(/\A[ \t]+(.+)\z/)
          # Continued line of the value of Diagnostic-Code header
          v['diagnosis'] << ' ' << cv[1]
          readslices[-1] = 'Diagnostic-Code: ' << e
        else
          if cv = e.match(/\AReporting-MTA:[ ]*(?:DNS|dns);[ ]*(.+)\z/)
            # 2.2.2 The Reporting-MTA DSN field
            #
            #       reporting-mta-field =
            #           "Reporting-MTA" ":" mta-name-type ";" mta-name
            #       mta-name = *text
            #
            #   The Reporting-MTA field is defined as follows:
            #
            #   A DSN describes the results of attempts to deliver, relay, or gateway
            #   a message to one or more recipients.  In all cases, the Reporting-MTA
            #   is the MTA that attempted to perform the delivery, relay, or gateway
            #   operation described in the DSN.  This field is required.
            connheader['rhost'] ||= cv[1].downcase

          elsif cv = e.match(/\AReceived-From-MTA:[ ]*(?:DNS|dns);[ ]*(.+)\z/)
            # 2.2.4 The Received-From-MTA DSN field
            #   The optional Received-From-MTA field indicates the name of the MTA
            #   from which the message was received.
            #
            #       received-from-mta-field =
            #           "Received-From-MTA" ":" mta-name-type ";" mta-name
            #
            #   If the message was received from an Internet host via SMTP, the
            #   contents of the mta-name sub-field SHOULD be the Internet domain name
            #   supplied in the HELO or EHLO command, and the network address used by
            #   the SMTP client SHOULD be included as a comment enclosed in
            #   parentheses.  (In this case, the MTA-name-type will be "dns".)
            connheader['lhost'] = cv[1].downcase

          elsif cv = e.match(/\AArrival-Date:[ ]*(.+)\z/)
            # 2.2.5 The Arrival-Date DSN field
            #   The optional Arrival-Date field indicates the date and time at which
            #   the message arrived at the Reporting MTA.  If the Last-Attempt-Date
            #   field is also provided in a per-recipient field, this can be used to
            #   determine the interval between when the message arrived at the
            #   Reporting MTA and when the report was issued for that recipient.
            #
            #       arrival-date-field = "Arrival-Date" ":" date-time
            connheader['date'] = cv[1]
          else
            # Get error message
            next if e.start_with?(' ', '-')
            next unless e =~ MarkingsOf[:error]

            # 500 User Unknown
            # <kijitora@example.jp> Unknown
            v['alterrors'] ||= ' '
            v['alterrors']  << ' ' << e
          end
        end
      end
    end # End of if: rfc822
  end

  while true
    # Fallback, parse entire message body
    break if recipients > 0
    match = 0
    mfrom = mhead['from'].downcase

    # Failed to get a recipient address at code above
    match += 1 if mfrom.include?('postmaster@') || mfrom.include?('mailer-daemon@') || mfrom.include?('root@')
    match += 1 if mhead['subject'].downcase =~ %r{(?>
       delivery[ ](?:failed|failure|report)
      |failure[ ]notice
      |mail[ ](?:delivery|error)
      |non[-]delivery
      |returned[ ]mail
      |undeliverable[ ]mail
      |warning:[ ]
      )
    }x

    if mhead['return-path']
      # Check the value of Return-Path of the message
      rpath  = mhead['return-path'].downcase
      match += 1 if rpath.include?('<>') || rpath.include?('mailer-daemon')
    end
    break unless match > 0

    b = dscontents[-1]
    bodyslices = mbody.split("\n")
    while e = bodyslices.shift do
      # Get the recipient's email address and error messages.
      d = e.downcase
      break if d =~ MarkingsOf[:rfc822]
      break if d =~ ReStop

      next if e.empty?
      next if d =~ ReSkip
      next if e.start_with?('*')

      if cv = e.match(ReAddr)
        # May be an email address
        x = b['recipient'] || ''
        y = Sisimai::Address.s3s4(cv[1])
        next unless Sisimai::RFC5322.is_emailaddress(y)

        if !x.empty? && x != y
          # There are multiple recipient addresses in the message body.
          dscontents << Sisimai::Lhost.DELIVERYSTATUS
          b = dscontents[-1]
        end
        b['recipient'] = y
        recipients += 1
        itisbounce ||= true

      elsif cv = e.match(/[(](?:expanded|generated)[ ]from:?[ ]([^@]+[@][^@]+)[)]/)
        # (expanded from: neko@example.jp)
        b['alias'] = Sisimai::Address.s3s4(cv[1])
      end
      b['diagnosis'] ||= ''
      b['diagnosis']  << ' ' << e
    end

    break
  end
  return nil unless itisbounce

  if recipients == 0 && cv = rfc822text.match(/^To:[ ]*(.+)/)
    # Try to get a recipient address from "To:" header of the original message
    if r = Sisimai::Address.find(cv[1], true)
      # Found a recipient address
      dscontents << Sisimai::Lhost.DELIVERYSTATUS if dscontents.size == recipients
      b = dscontents[-1]
      b['recipient'] = r[0][:address]
      recipients += 1
    end
  end
  return nil unless recipients > 0

  require 'sisimai/mda'
  mdabounced = Sisimai::MDA.make(mhead, mbody)
  dscontents.each do |e|
    # Set default values if each value is empty.
    connheader.each_key { |a| e[a] ||= connheader[a] || '' }

    if e['alterrors']
      # Copy alternative error message
      unless e['alterrors'].empty?
        e['diagnosis'] ||= e['alterrors']
        if e['diagnosis'].start_with?('-') || e['diagnosis'].end_with?('__')
          # Override the value of diagnostic code message
          e['diagnosis'] = e['alterrors']
        end
        e.delete('alterrors')
      end
    end
    e['diagnosis'] = Sisimai::String.sweep(e['diagnosis']) || ''

    if mdabounced
      # Make bounce data by the values returned from Sisimai::MDA.make()
      e['agent']     = mdabounced['mda'] || 'RFC3464'
      e['reason']    = mdabounced['reason'] || 'undefined'
      e['diagnosis'] = mdabounced['message'] unless mdabounced['message'].empty?
      e['command']   = ''
    end

    e['status'] ||= Sisimai::SMTP::Status.find(e['diagnosis']) || ''
    if cv = e['diagnosis'].match(MarkingsOf[:command])
      e['command'] = cv[1]
    end
    e['date'] ||= mhead['date']
  end

  return { 'ds' => dscontents, 'rfc822' => rfc822text }
end