module Sisimai::Lhost::Exim

Sisimai::Lhost::Exim parses a bounce email which created by Exim. Methods in the module are called from only Sisimai::Message.

Constants

DelayedFor

retry.c:902| addr->message = (addr->message == NULL)? US“retry timeout exceeded” : deliver.c:7475| “No action is required on your part. Delivery attempts will continue forn” smtp.c:3508| US“retry time not reached for any host after a long failure period” : smtp.c:3508| US“all hosts have been failing for a long time and were last tried ”

"after this message arrived";

deliver.c:7459| print_address_error(addr, f, US“Delay reason: ”); deliver.c:7586| “Message %s has been frozen%s.nThe sender is <%s>.n”, message_id, receive.c:4021| moan_tell_someone(freeze_tell, NULL, US“Message frozen on arrival”, receive.c:4022| “Message %s was frozen on arrival by %s.nThe sender is <%s>.n”,

Indicators
MarkingsOf
MessagesOf
ReBackbone

deliver.c:6423| if (bounce_return_body) fprintf(f, deliver.c:6424|“—— This is a copy of the message, including all the headers. ——n”); deliver.c:6425| else fprintf(f, deliver.c:6426|“—— This is a copy of the message's headers. ——n”);

ReCommands
StartingOf

Public Class Methods

description() click to toggle source
# File lib/sisimai/lhost/exim.rb, line 490
def description; return 'Exim'; end
make(mhead, mbody) click to toggle source

Parse bounce messages from Exim @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/lhost/exim.rb, line 128
def make(mhead, mbody)
  return nil if mhead['from'] =~ /[@].+[.]mail[.]ru[>]?/

  # Message-Id: <E1P1YNN-0003AD-Ga@example.org>
  # X-Failed-Recipients: kijitora@example.ed.jp
  match  = 0
  match += 1 if mhead['from'].start_with?('Mail Delivery System')
  match += 1 if mhead['message-id'].to_s =~ %r/\A[<]\w{7}[-]\w{6}[-]\w{2}[@]/
  match += 1 if mhead['subject'] =~ %r{(?:
     Mail[ ]delivery[ ]failed(:[ ]returning[ ]message[ ]to[ ]sender)?
    |Warning:[ ]message[ ][^ ]+[ ]delayed[ ]+
    |Delivery[ ]Status[ ]Notification
    |Mail[ ]failure
    |Message[ ]frozen
    |error[(]s[)][ ]in[ ]forwarding[ ]or[ ]filtering
    )
  }x
  return nil if match < 2

  require 'sisimai/rfc1894'
  fieldtable = Sisimai::RFC1894.FIELDTABLE
  dscontents = [Sisimai::Lhost.DELIVERYSTATUS]
  emailsteak = Sisimai::RFC5322.fillet(mbody, ReBackbone)
  bodyslices = emailsteak[0].split("\n")
  readcursor = 0      # (Integer) Points the current cursor position
  nextcursor = 0
  recipients = 0      # (Integer) The number of 'Final-Recipient' header
  localhost0 = ''     # (String) Local MTA
  boundary00 = ''     # (String) Boundary string
  v = nil

  if mhead['content-type']
    # Get the boundary string and set regular expression for matching with
    # the boundary string.
    boundary00 = Sisimai::MIME.boundary(mhead['content-type']) || ''
  end

  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.
    if readcursor == 0
      # Beginning of the bounce message or message/delivery-status part
      if e =~ MarkingsOf[:message]
        readcursor |= Indicators[:deliverystatus]
        next unless e =~ MarkingsOf[:frozen]
      end
    end
    next if (readcursor & Indicators[:deliverystatus]) == 0
    next if e.empty?

    # This message was created automatically by mail delivery software.
    #
    # A message that you sent could not be delivered to one or more of its
    # recipients. This is a permanent error. The following address(es) failed:
    #
    #  kijitora@example.jp
    #    SMTP error from remote mail server after RCPT TO:<kijitora@example.jp>:
    #    host neko.example.jp [192.0.2.222]: 550 5.1.1 <kijitora@example.jp>... User Unknown
    v = dscontents[-1]

    if cv = e.match(/\A[ \t]{2}([^ \t]+[@][^ \t]+[.]?[a-zA-Z]+)(:.+)?\z/) ||
            e.match(/\A[ \t]{2}[^ \t]+[@][^ \t]+[.][a-zA-Z]+[ ]<(.+?[@].+?)>:.+\z/) ||
            e.match(MarkingsOf[:alias])
      #   kijitora@example.jp
      #   sabineko@example.jp: forced freeze
      #   mikeneko@example.jp <nekochan@example.org>: ...
      #
      # deliver.c:4549|  printed = US"an undisclosed address";
      #   an undisclosed address
      #     (generated from kijitora@example.jp)
      r = cv[1]

      if v['recipient']
        # There are multiple recipient addresses in the message body.
        dscontents << Sisimai::Lhost.DELIVERYSTATUS
        v = dscontents[-1]
      end
      # v['recipient'] = cv[1]

      if cv = e.match(/\A[ \t]+[^ \t]+[@][^ \t]+[.][a-zA-Z]+[ ]<(.+?[@].+?)>:.+\z/)
        # parser.c:743| while (bracket_count-- > 0) if (*s++ != '>')
        # parser.c:744|   {
        # parser.c:745|   *errorptr = s[-1] == 0
        # parser.c:746|     ? US"'>' missing at end of address"
        # parser.c:747|     : string_sprintf("malformed address: %.32s may not follow %.*s",
        # parser.c:748|     s-1, (int)(s - US mailbox - 1), mailbox);
        # parser.c:749|   goto PARSE_FAILED;
        # parser.c:750|   }
        r = cv[1]
        v['diagnosis'] = e
      end
      v['recipient'] = r
      recipients += 1

    elsif cv = e.match(/\A[ ]+[(]generated[ ]from[ ](.+)[)]\z/) ||
               e.match(/\A[ ]+generated[ ]by[ ]([^ \t]+[@][^ \t]+)/)
      #     (generated from kijitora@example.jp)
      #  pipe to |/bin/echo "Some pipe output"
      #    generated by userx@myhost.test.ex
      v['alias'] = cv[1]
    else
      next if e.empty?

      if e =~ MarkingsOf[:frozen]
        # Message *** has been frozen by the system filter.
        # Message *** was frozen on arrival by ACL.
        v['alterrors'] ||= ''
        v['alterrors'] <<  e + ' '
      else
        if !boundary00.empty?
          # --NNNNNNNNNN-eximdsn-MMMMMMMMMM
          # Content-type: message/delivery-status
          # ...
          if Sisimai::RFC1894.match(e)
            # "e" matched with any field defined in RFC3464
            next unless o = Sisimai::RFC1894.field(e)

            if o[-1] == 'addr'
              # Final-Recipient: rfc822; kijitora@example.jp
              # X-Actual-Recipient: rfc822; kijitora@example.co.jp
              next unless o[0] == 'final-recipient'
              v['spec'] ||= o[2].include?('@') ? 'SMTP' : 'X-UNIX'

            elsif o[-1] == 'code'
              # Diagnostic-Code: SMTP; 550 5.1.1 <userunknown@example.jp>... User Unknown
              v['spec'] = o[1]
              v['diagnosis'] = o[2]

            else
              # Other DSN fields defined in RFC3464
              next unless fieldtable[o[0]]
              v[fieldtable[o[0]]] = o[2]
            end
          else
            # Error message ?
            next if nextcursor == 1

            # Content-type: message/delivery-status
            nextcursor = 1 if e.start_with?(StartingOf[:deliverystatus][0])
            v['alterrors'] ||= ''
            if e.start_with?("\s", "\t")
              e.sub!(/\A[\s\t]+/, '')
              v['alterrors'] << e + ' ' unless v['alterrors'].include?(e)
            end
          end
        else
          if dscontents.size == recipients
            # Error message
            next if e.empty?
            v['diagnosis'] ||= ''
            v['diagnosis'] << e + '  '
          else
            # Error message when email address above does not include '@'
            # and domain part.
            if e =~ %r<\A[ ]+pipe[ ]to[ ][|]/[^ ]+>
              # pipe to |/path/to/prog ...
              #   generated by kijitora@example.com
              v['diagnosis'] = e
            else
              next unless e.start_with?('    ')
              v['alterrors'] ||= ''
              v['alterrors'] << e + ' '
            end
          end
        end
      end
    end
  end

  if recipients > 0
    # Check "an undisclosed address", "unroutable address"
    dscontents.each do |q|
      # Replace the recipient address with the value of "alias"
      next unless q['alias']
      next if q['alias'].empty?
      if q['recipient'].empty? || q['recipient'].include?('@') == false
        # The value of "recipient" is empty or does not include "@"
        q['recipient'] = q['alias']
      end
    end
  else
    # Fallback for getting recipient addresses
    if mhead['x-failed-recipients']
      # X-Failed-Recipients: kijitora@example.jp
      rcptinhead = mhead['x-failed-recipients'].split(',')
      rcptinhead.each do |a|
        # Remove space characters
        a.lstrip!
        a.rstrip!
      end
      recipients = rcptinhead.size

      while e = rcptinhead.shift do
        # Insert each recipient address into dscontents
        dscontents[-1]['recipient'] = e
        next if dscontents.size == recipients
        dscontents << Sisimai::Lhost.DELIVERYSTATUS
      end
    end
  end
  return nil unless recipients > 0

  unless mhead['received'].empty?
    # Get the name of local MTA
    # Received: from marutamachi.example.org (c192128.example.net [192.0.2.128])
    if cv = mhead['received'][-1].match(/from[ \t]([^ ]+)/) then localhost0 = cv[1] end
  end

  dscontents.each do |e|
    # Set default values if each value is empty.
    e['lhost'] ||= localhost0

    unless e['diagnosis']
      # Empty Diagnostic-Code: or error message
      unless boundary00.empty?
        # --NNNNNNNNNN-eximdsn-MMMMMMMMMM
        # Content-type: message/delivery-status
        #
        # Reporting-MTA: dns; the.local.host.name
        #
        # Action: failed
        # Final-Recipient: rfc822;/a/b/c
        # Status: 5.0.0
        #
        # Action: failed
        # Final-Recipient: rfc822;|/p/q/r
        # Status: 5.0.0
        e['diagnosis'] = dscontents[0]['diagnosis'] || ''
        e['spec']    ||= dscontents[0]['spec']

        unless dscontents[0]['alterrors'].to_s.empty?
          # The value of "alterrors" is also copied
          e['alterrors'] = dscontents[0]['alterrors']
        end
      end
    end

    unless e['alterrors'].to_s.empty?
      # Copy alternative error message
      if e['diagnosis'].nil? || e['diagnosis'].empty?
        e['diagnosis'] = e['alterrors']
      end

      if e['diagnosis'].start_with?('-') || e['diagnosis'].end_with?('__')
        # Override the value of diagnostic code message
        e['diagnosis'] = e['alterrors'] unless e['alterrors'].empty?
      else
        # Check the both value and try to match
        if e['diagnosis'].size < e['alterrors'].size
          # Check the value of alterrors
          rxdiagnosis = %r/e['diagnosis']/i
          # Override the value of diagnostic code message because
          # the value of alterrors includes the value of diagnosis.
          e['diagnosis'] = e['alterrors'] if e['alterrors'].downcase.include?(e['diagnosis'].downcase)
        end
      end
      e.delete('alterrors')
    end
    e['diagnosis'] = Sisimai::String.sweep(e['diagnosis']) || ''
    e['diagnosis'].sub!(/\b__.+\z/, '')

    unless e['rhost']
      # Get the remote host name
      # host neko.example.jp [192.0.2.222]: 550 5.1.1 <kijitora@example.jp>... User Unknown
      if cv = e['diagnosis'].match(/host[ \t]+([^ \t]+)[ \t]\[.+\]:[ \t]/) then e['rhost'] = cv[1] end

      unless e['rhost']
        # Get localhost and remote host name from Received header.
        e['rhost'] = Sisimai::RFC5322.received(mhead['received'][-1]).pop unless mhead['received'].empty?
      end
    end

    unless e['command']
      # Get the SMTP command name for the session
      ReCommands.each do |r|
        # Verify each regular expression of SMTP commands
        if cv = e['diagnosis'].match(r)
          e['command'] = cv[1].upcase
          break
        end
      end

      # Detect the reason of bounce
      if %w[HELO EHLO].index(e['command'])
        # HELO | Connected to 192.0.2.135 but my name was rejected.
        e['reason'] = 'blocked'

      elsif e['command'] == 'MAIL'
        # MAIL | Connected to 192.0.2.135 but sender was rejected.
        e['reason'] = 'onhold'
      else
        # Verify each regular expression of session errors
        MessagesOf.each_key do |r|
          # Check each regular expression
          next unless MessagesOf[r].any? { |a| e['diagnosis'].include?(a) }
          e['reason'] = r
          break
        end

        unless e['reason']
          # The reason "expired"
          e['reason'] = 'expired' if DelayedFor.any? { |a| e['diagnosis'].include?(a) }
        end
      end
    end

    # Prefer the value of smtp reply code in Diagnostic-Code:
    # See eg/maildir-as-a-sample/new/exim-20.eml
    #   Action: failed
    #   Final-Recipient: rfc822;userx@test.ex
    #   Status: 5.0.0
    #   Remote-MTA: dns; 127.0.0.1
    #   Diagnostic-Code: smtp; 450 TEMPERROR: retry timeout exceeded
    # The value of "Status:" indicates permanent error but the value
    # of SMTP reply code in Diagnostic-Code: field is "TEMPERROR"!!!!
    sv = Sisimai::SMTP::Status.find(e['diagnosis'])
    rv = Sisimai::SMTP::Reply.find(e['diagnosis'])
    s1 = 0  # First character of Status as integer
    r1 = 0  # First character of SMTP reply code as integer

    while true
      # "Status:" field did not exist in the bounce message
      break if sv
      break unless rv

      # Check SMTP reply code
      # Generate pseudo DSN code from SMTP reply code
      r1 = rv[0, 1].to_i
      if r1 == 4
        # Get the internal DSN(temporary error)
        sv = Sisimai::SMTP::Status.code(e['reason'], true)

      elsif r1 == 5
        # Get the internal DSN(permanent error)
        sv = Sisimai::SMTP::Status.code(e['reason'], false)
      end
      break
    end

    s1 = sv[0, 1].to_i if sv
    v1 = s1 + r1
    v1 << e['status'][0, 1].to_i if e['status']

    if v1 > 0
      # Status or SMTP reply code exists
      # Set pseudo DSN into the value of "status" accessor
      e['status'] = sv if r1 > 0
    else
      # Neither Status nor SMTP reply code exist
      sv = if %w[expired mailboxfull].include?(e['reason'])
             # Set pseudo DSN (temporary error)
             Sisimai::SMTP::Status.code(e['reason'], true)
           else
             # Set pseudo DSN (permanent error)
             Sisimai::SMTP::Status.code(e['reason'], false)
           end
    end
    e['status'] ||= sv.to_s
  end

  return { 'ds' => dscontents, 'rfc822' => emailsteak[1] }
end