module Sisimai::Lhost::SendGrid

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

Constants

Indicators
ReBackbone
StartingOf

Public Class Methods

description() click to toggle source
# File lib/sisimai/lhost/sendgrid.rb, line 146
def description; return 'SendGrid: https://sendgrid.com/'; end
make(mhead, mbody) click to toggle source

Parse bounce messages from SendGrid @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/sendgrid.rb, line 18
def make(mhead, mbody)
  # Return-Path: <apps@sendgrid.net>
  # X-Mailer: MIME-tools 5.502 (Entity 5.502)
  return nil unless mhead['return-path']
  return nil unless mhead['return-path'] == '<apps@sendgrid.net>'
  return nil unless mhead['subject'] == 'Undelivered Mail Returned to Sender'

  require 'sisimai/rfc1894'
  fieldtable = Sisimai::RFC1894.FIELDTABLE
  permessage = {}     # (Hash) Store values of each Per-Message field

  dscontents = [Sisimai::Lhost.DELIVERYSTATUS]
  emailsteak = Sisimai::RFC5322.fillet(mbody, ReBackbone)
  bodyslices = emailsteak[0].split("\n")
  readslices = ['']
  readcursor = 0      # (Integer) Points the current cursor position
  recipients = 0      # (Integer) The number of 'Final-Recipient' header
  commandtxt = ''     # (String) SMTP Command name begin with the string '>>>'
  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

    if readcursor == 0
      # Beginning of the bounce message or message/delivery-status part
      readcursor |= Indicators[:deliverystatus] if e == StartingOf[:message][0]
      next
    end
    next if (readcursor & Indicators[:deliverystatus]) == 0
    next if e.empty?

    if f = Sisimai::RFC1894.match(e)
      # "e" matched with any field defined in RFC3464
      o = Sisimai::RFC1894.field(e)
      v = dscontents[-1]

      unless o
        # Fallback code for empty value or invalid formatted value
        # - Status: (empty)
        # - Diagnostic-Code: 550 5.1.1 ... (No "diagnostic-type" sub field)
        next unless cv = e.match(/\ADiagnostic-Code:[ ]*(.+)/)
        v['diagnosis'] = cv[1]
        next
      end

      if o[-1] == 'addr'
        # Final-Recipient: rfc822; kijitora@example.jp
        # X-Actual-Recipient: rfc822; kijitora@example.co.jp
        if o[0] == 'final-recipient'
          # Final-Recipient: rfc822; kijitora@example.jp
          if v['recipient']
            # There are multiple recipient addresses in the message body.
            dscontents << Sisimai::Lhost.DELIVERYSTATUS
            v = dscontents[-1]
          end
          v['recipient'] = o[2]
          recipients += 1
        else
          # X-Actual-Recipient: rfc822; kijitora@example.co.jp
          v['alias'] = o[2]
        end
      elsif o[-1] == 'code'
        # Diagnostic-Code: SMTP; 550 5.1.1 <userunknown@example.jp>... User Unknown
        v['spec'] = o[1]
        v['diagnosis'] = o[2]
      elsif o[-1] == 'date'
        # Arrival-Date: 2012-12-31 23-59-59
        next unless cv = e.match(/\AArrival-Date: (\d{4})[-](\d{2})[-](\d{2}) (\d{2})[-](\d{2})[-](\d{2})\z/)
        o[1] << 'Thu, ' << cv[3] + ' '
        o[1] << Sisimai::DateTime.monthname(false)[cv[2].to_i - 1]
        o[1] << ' ' << cv[1] + ' ' << [cv[4], cv[5], cv[6]].join(':')
        o[1] << ' ' << Sisimai::DateTime.abbr2tz('CDT')
      else
        # Other DSN fields defined in RFC3464
        next unless fieldtable[o[0]]
        v[fieldtable[o[0]]] = o[2]

        next unless f == 1
        permessage[fieldtable[o[0]]] = o[2]
      end
    else
      # The line does not begin with a DSN field defined in RFC3464
      if cv = e.match(/.+ in (?:End of )?([A-Z]{4}).*\z/)
        # in RCPT TO, in MAIL FROM, end of DATA
        commandtxt = cv[1]
      else
        # Continued line of the value of Diagnostic-Code field
        next unless readslices[-2].start_with?('Diagnostic-Code:')
        next unless cv = e.match(/\A[ \t]+(.+)\z/)
        v['diagnosis'] << ' ' << cv[1]
        readslices[-1] = 'Diagnostic-Code: ' << e
      end
    end
  end
  return nil unless recipients > 0

  dscontents.each do |e|
    # Get the value of SMTP status code as a pseudo D.S.N.
    e['diagnosis'] = Sisimai::String.sweep(e['diagnosis'])
    if cv = e['diagnosis'].match(/\b([45])\d\d[ \t]*/)
      # 4xx or 5xx
      e['status'] = cv[1] + '.0.0'
    end

    if e['status'] == '5.0.0' || e['status'] == '4.0.0'
      # Get the value of D.S.N. from the error message or the value of
      # Diagnostic-Code header.
      e['status'] = Sisimai::SMTP::Status.find(e['diagnosis']) || e['status']
    end

    if e['action'] == 'expired'
      # Action: expired
      e['reason'] = 'expired'
      if !e['status'] || e['status'].end_with?('.0.0')
        # Set pseudo Status code value if the value of Status is not
        # defined or 4.0.0 or 5.0.0.
        e['status'] = Sisimai::SMTP::Status.code('expired') || e['status']
      end
    end

    e['lhost'] ||= permessage['rhost']
    e['command'] = commandtxt
  end

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