diff --git a/lib/sisimai/rfc3464.rb b/lib/sisimai/rfc3464.rb index c6286854..017a6106 100644 --- a/lib/sisimai/rfc3464.rb +++ b/lib/sisimai/rfc3464.rb @@ -284,7 +284,7 @@ def make(mhead, mbody) 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] - e = 'Diagnostic-Code: ' << e + readslices[-1] = 'Diagnostic-Code: ' << e else if cv = e.match(/\AReporting-MTA:[ ]*(?:DNS|dns);[ ]*(.+)\z/) # 2.2.2 The Reporting-MTA DSN field diff --git a/lib/sisimai/rhost.rb b/lib/sisimai/rhost.rb index 0be318b7..21eb1fc0 100644 --- a/lib/sisimai/rhost.rb +++ b/lib/sisimai/rhost.rb @@ -20,6 +20,7 @@ class << self 'lsean.ezweb.ne.jp' => 'KDDI', 'msmx.au.com' => 'KDDI', 'charter.net' => 'Spectrum', + 'cox.net' => 'Cox', '.qq.com' => 'TencentQQ', }.freeze diff --git a/lib/sisimai/rhost/cox.rb b/lib/sisimai/rhost/cox.rb new file mode 100644 index 00000000..4659c9a4 --- /dev/null +++ b/lib/sisimai/rhost/cox.rb @@ -0,0 +1,112 @@ +module Sisimai + module Rhost + # Sisimai::Rhost detects the bounce reason from the content of Sisimai::Data + # object as an argument of get() method when the value of "destination" of + # the object is "charter.net". This class is called only Sisimai::Data class. + module Cox + class << self + # Imported from p5-Sisimail/lib/Sisimai/Rhost/Cox.pm + ErrorCodes = { + # https://www.cox.com/residential/support/email-error-codes.html + 'CXBL' => 'blocked', # The sending IP address has been blocked by Cox due to exhibiting spam-like behavior. + 'CXTHRT' => 'securityerror', # Email sending limited due to suspicious account activity. + 'CXMJ' => 'securityerror', # Email sending blocked due to suspicious account activity on primary Cox account. + 'CXDNS' => 'blocked', # There was an issue with the connecting IP address Domain Name System (DNS). + 'CXSNDR' => 'rejected', # There was a problem with the sender's domain. + 'CXSMTP' => 'rejected', # Your email wasn't delivered because Cox was unable to verify that it came from a legitimate email sender. + 'CXCNCT' => 'toomanyconn', # There is a limit to the number of concurrent SMTP connections per IP address + 'CXMXRT' => 'toomanyconn', # The email sender has exceeded the maximum number of sent email allowed. + 'CDRBL' => 'blocked', # The sending IP address has been temporarily blocked by Cox due to exhibiting spam-like behavior. + 'IPBL0001' => 'blocked', # The sending IP address is listed in the Spamhaus Zen DNSBL. + 'IPBL0010' => 'blocked', # The sending IP is listed in the Return Path DNSBL. + 'IPBL0100' => 'blocked', # The sending IP is listed in the Invaluement ivmSIP DNSBL. + 'IPBL0011' => 'blocked', # The sending IP is in the Spamhaus Zen and Return Path DNSBLs. + 'IPBL0101' => 'blocked', # The sending IP is in the Spamhaus Zen and Invaluement ivmSIP DNSBLs. + 'IPBL0110' => 'blocked', # The sending IP is in the Return Path and Invaluement ivmSIP DNSBLs. + 'IPBL0111' => 'blocked', # The sending IP is in the Spamhaus Zen, Return Path and Invaluement ivmSIP DNSBLs. + 'IPBL1000' => 'blocked', # The sending IP address is listed on a CSI blacklist. You can check your status on the CSI website. + 'IPBL1001' => 'blocked', # The sending IP is listed in the Cloudmark CSI and Spamhaus Zen DNSBLs. + 'IPBL1010' => 'blocked', # The sending IP is listed in the Cloudmark CSI and Return Path DNSBLs. + 'IPBL1011' => 'blocked', # The sending IP is in the Cloudmark CSI, Spamhaus Zen and Return Path DNSBLs. + 'IPBL1100' => 'blocked', # The sending IP is listed in the Cloudmark CSI and Invaluement ivmSIP DNSBLs. + 'IPBL1101' => 'blocked', # The sending IP is in the Cloudmark CSI, Spamhaus Zen and Invaluement IVMsip DNSBLs. + 'IPBL1110' => 'blocked', # The sending IP is in the Cloudmark CSI, Return Path and Invaluement ivmSIP DNSBLs. + 'IPBL1111' => 'blocked', # The sending IP is in the Cloudmark CSI, Spamhaus Zen, Return Path and Invaluement ivmSIP DNSBLs. + 'IPBL00001' => 'blocked', # The sending IP address is listed on a Spamhaus blacklist. Check your status at Spamhaus. + 'URLBL011' => 'spamdetected', # A URL within the body of the message was found on blocklists SURBL and Spamhaus DBL. + 'URLBL101' => 'spamdetected', # A URL within the body of the message was found on blocklists SURBL and ivmURI. + 'URLBL110' => 'spamdetected', # A URL within the body of the message was found on blocklists Spamhaus DBL and ivmURI. + 'URLBL1001' => 'spamdetected', # The URL is listed on a Spamhaus blacklist. Check your status at Spamhaus. + }.freeze + MessagesOf = { + 'blocked' => [ + # Cox requires that all connecting email servers contain valid reverse DNS PTR records. + 'rejected - no rDNS', + # An email client has repeatedly sent bad commands or invalid passwords resulting in a three-hour block of the client's IP address. + 'cox too many bad commands from', + # The reverse DNS check of the sending server IP address has failed. + 'DNS check failure - try again later', + # The sending IP address has exceeded the threshold of invalid recipients and has been blocked. + 'Too many invalid recipients', + ], + 'notaccept' => [ + # Our systems are experiencing an issue which is causing a temporary inability to accept new email. + 'ESMTP server temporarily not available', + ], + 'policyviolation' => [ + # The sending server has attempted to communicate too soon within the SMTP transaction + 'ESMTP no data before greeting', + # The message has been rejected because it contains an attachment with one of the following prohibited + # file types, which commonly contain viruses: .shb, .shs, .vbe, .vbs, .wsc, .wsf, .wsh, .pif, .msc, + # .msi, .msp, .reg, .sct, .bat, .chm, .isp, .cpl, .js, .jse, .scr, .exe. + 'attachment extension is forbidden', + ], + 'rejected' => [ + # Cox requires that all sender domains resolve to a valid MX or A-record within DNS. + 'sender rejected', + ], + 'toomanyconn' => [ + # The sending IP address has exceeded the five maximum concurrent connection limit. + 'too many sessions from', + # The SMTP connection has exceeded the 100 email message threshold and was disconnected. + 'requested action aborted: try again later', + # The sending IP address has exceeded one of these rate limits and has been temporarily blocked. + 'Message threshold exceeded', + ], + 'userunknown' => [ + 'recipient rejected', # The intended recipient is not a valid Cox Email account. + ], + }.freeze + + # Detect bounce reason from https://cox.com/ + # @param [Sisimai::Data] argvs Parsed email object + # @return [String, Nil] The bounce reason at Cox + # @since v4.25.8 + def get(argvs) + statusmesg = argvs.diagnosticcode + codenumber = 0 + + if cv = statusmesg.match(/AUP#([0-9A-Z]+)/) + # Capture the numeric part of the error code + codenumber = cv[1] + end + reasontext = ErrorCodes[codenumber] || '' + + if reasontext.empty? + # The error code was not found in ErrorCodes + MessagesOf.each_key do |e| + # Try to find with each error message defined in MessagesOf + next unless MessagesOf[e].any? { |a| statusmesg.include?(a) } + reasontext = e + break + end + end + + return reasontext + end + + end + end + end +end + diff --git a/set-of-emails/maildir/bsd/rhost-cox-01.eml b/set-of-emails/maildir/bsd/rhost-cox-01.eml new file mode 100644 index 00000000..eb602293 --- /dev/null +++ b/set-of-emails/maildir/bsd/rhost-cox-01.eml @@ -0,0 +1,192 @@ +From MAILER-DAEMON Thu Jul 2 12:05:05 2020 +Received: from mailer76.example.com (mailer76.example.com [111.22.33.44]) + by bouncehandler.example.com (Postfix) with ESMTPS id 7939F4114A + for ; Thu, 2 Jul 2020 12:05:05 -0400 (EDT) +Received: by tr2.example.com (Postfix) + id 6F3F839637A1; Thu, 2 Jul 2020 12:05:05 -0400 (EDT) +Date: Thu, 2 Jul 2020 12:05:05 -0400 (EDT) +From: MAILER-DAEMON@tr2.example.com (Mail Delivery System) +Subject: Undelivered Mail Returned to Sender +To: bounce@mailer.cnt1.example.com +Auto-Submitted: auto-replied +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="D9E933966464.1593705905/tr2.example.com" +Content-Transfer-Encoding: 8bit +Message-Id: <20200702160505.6F3F839637A1@tr2.example.com> + +This is a MIME-encapsulated message. + +--D9E933966464.1593705905/tr2.example.com +Content-Description: Notification +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 8bit + +This is the mail system at host tr2.example.com. + +I'm sorry to have to inform you that your message could not +be delivered to one or more recipients. It's attached below. + +For further assistance, please send mail to postmaster. + +If you do so, please include this problem report. You can +delete your own text from the attached returned message. + + The mail system + +: host cxr.mx.a.cloudfilter.net[34.212.80.54] said: 550 + 5.1.0 sender rejected. Refer to Error + Codes section at + https://www.cox.com/residential/support/email-error-codes.html for more + information. AUP#CDRBL (in reply to MAIL FROM command) + +--D9E933966464.1593705905/tr2.example.com +Content-Description: Delivery report +Content-Type: message/delivery-status + +Reporting-MTA: dns; tr2.example.com +X-Postfix-Queue-ID: D9E933966464 +X-Postfix-Sender: rfc822; bounce@mailer.cnt1.example.com +Arrival-Date: Thu, 2 Jul 2020 06:04:42 -0400 (EDT) + +Final-Recipient: rfc822; recipient55@cox.net +Original-Recipient: rfc822;recipient55@cox.net +Action: failed +Status: 5.1.0 +Remote-MTA: dns; cxr.mx.a.cloudfilter.net +Diagnostic-Code: smtp; 550 5.1.0 sender + rejected. Refer to Error Codes section at + https://www.cox.com/residential/support/email-error-codes.html for more + information. AUP#CDRBL + +--D9E933966464.1593705905/tr2.example.com +Content-Description: Undelivered Message Headers +Content-Type: text/rfc822-headers +Content-Transfer-Encoding: 8bit + +Return-Path: +Received: from pixel1.example.com (pixel1 [104.207.134.97]) + by tr2.example.com (Postfix) with ESMTPA id D9E933966464 + for ; Thu, 2 Jul 2020 06:04:42 -0400 (EDT) +DKIM-Filter: OpenDKIM Filter v2.11.0 tr2.example.com D9E933966464 +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; + s=mail; t=1593684282; + bh=QCJqru6dH/ii/ts4AH9NsNa/v562Fc43deLk8w1KKJ8=; + h=To:Subject:Date:From:Reply-To:List-Unsubscribe:From; + b=EXtMRJLuGhK9gq/gZspsdSSTRmLXOTxQstFekcdNQsxLxKEHmOijeZWiDlz8W8FKn + 95nVfTtlOk8dCFE/4EeLyBWlPoQ0EdpLPUcEcwHbBynoCQHwRI1iUiE4r2+Mc5iX51 + 1eRfb2xphpVwWyJhA2I7qXAeOuJ27J6cdRI7ijlI= +Received: by pixel1.example.com (Postfix, from userid 501) + id C3726626BC; Thu, 2 Jul 2020 06:04:42 -0400 (EDT) +To: "recipient55@cox.net" +Subject: Take down your patio umbrella +X-PHP-Originating-Script: 501:phpmailer.php +Date: Thu, 2 Jul 2020 06:04:42 -0400 +From: deas +Reply-To: Example +Message-ID: <5119e0e9531a692224d1bd44ef560e62@pixel1.example.com> +X-Priority: 3 +X-Mailer: PHPMailer 5.2.7 (https://github.com/PHPMailer/PHPMailer/) +X-se: fol_new_t +X-date: 20200702 +X-version: version_C_ht +X-SES-CONFIGURATION-SET: ded-content +X-CONFIGSET: conf-content +Feedback-ID: 21053362:version_C_ht:newsletter:example +List-Unsubscribe: https://www.example.com/unsubscribe +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="b1_5119e0e9531a692224d1bd44ef560e62" +Content-Transfer-Encoding: 8bit + +--D9E933966464.1593705905/tr2.example.com-- +From double-bounce@tr2.example.com Thu Jul 2 12:05:05 2020 +Received: from mailer74.example.com (mailer74.example.com [108.61.68.74]) + by bouncehandler.example.com (Postfix) with ESMTPS id 803294114A + for ; Thu, 2 Jul 2020 12:05:05 -0400 (EDT) +Received: by tr2.example.com (Postfix) + id 75BA239637A5; Thu, 2 Jul 2020 12:05:05 -0400 (EDT) +Date: Thu, 2 Jul 2020 12:05:05 -0400 (EDT) +From: MAILER-DAEMON@tr2.example.com (Mail Delivery System) +Subject: Postmaster Copy: Undelivered Mail +To: bounce@mailer.example.com +Auto-Submitted: auto-generated +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="D9E933966464.1593705905/tr2.example.com" +Content-Transfer-Encoding: 8bit +Message-Id: <20200702160505.75BA239637A5@tr2.example.com> + +This is a MIME-encapsulated message. + +--D9E933966464.1593705905/tr2.example.com +Content-Description: Notification +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 8bit + + +: host cxr.mx.a.cloudfilter.net[34.212.80.54] said: 550 + 5.1.0 sender rejected. Refer to Error + Codes section at + https://www.cox.com/residential/support/email-error-codes.html for more + information. AUP#CDRBL (in reply to MAIL FROM command) + +--D9E933966464.1593705905/tr2.example.com +Content-Description: Delivery report +Content-Type: message/delivery-status + +Reporting-MTA: dns; tr2.example.com +X-Postfix-Queue-ID: D9E933966464 +X-Postfix-Sender: rfc822; bounce@mailer.cnt1.example.com +Arrival-Date: Thu, 2 Jul 2020 06:04:42 -0400 (EDT) + +Final-Recipient: rfc822; recipient55@cox.net +Original-Recipient: rfc822;recipient55@cox.net +Action: failed +Status: 5.1.0 +Remote-MTA: dns; cxr.mx.a.cloudfilter.net +Diagnostic-Code: smtp; 550 5.1.0 sender + rejected. Refer to Error Codes section at + https://www.cox.com/residential/support/email-error-codes.html for more + information. AUP#CDRBL + +--D9E933966464.1593705905/tr2.example.com +Content-Description: Undelivered Message Headers +Content-Type: text/rfc822-headers +Content-Transfer-Encoding: 8bit + +Return-Path: +Received: from pixel1.example.com (pixel1 [111.22.33.44]) + by tr2.example.com (Postfix) with ESMTPA id D9E933966464 + for ; Thu, 2 Jul 2020 06:04:42 -0400 (EDT) +DKIM-Filter: OpenDKIM Filter v2.11.0 tr2.example.com D9E933966464 +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; + s=mail; t=1593684282; + bh=QCJqru6dH/ii/ts4AH9NsNa/v562Fc43deLk8w1KKJ8=; + h=To:Subject:Date:From:Reply-To:List-Unsubscribe:From; + b=EXtMRJLuGhK9gq/gZspsdSSTRmLXOTxQstFekcdNQsxLxKEHmOijeZWiDlz8W8FKn + 95nVfTtlOk8dCFE/4EeLyBWlPoQ0EdpLPUcEcwHbBynoCQHwRI1iUiE4r2+Mc5iX51 + 1eRfb2xphpVwWyJhA2I7qXAeOuJ27J6cdRI7ijlI= +Received: by pixel1.example.com (Postfix, from userid 501) + id C3726626BC; Thu, 2 Jul 2020 06:04:42 -0400 (EDT) +To: "recipient55@cox.net" +Subject: Take down your patio umbrella +X-PHP-Originating-Script: 501:phpmailer.php +Date: Thu, 2 Jul 2020 06:04:42 -0400 +From: Ideas +Reply-To: Example +Message-ID: <5119e0e9531a692224d1bd44ef560e62@pixel1.example.com> +X-Priority: 3 +X-Mailer: PHPMailer 5.2.7 (https://github.com/PHPMailer/PHPMailer/) +X-se: fol_new_t +X-date: 20200702 +X-version: version_C_ht +X-SES-CONFIGURATION-SET: ded-content +X-CONFIGSET: conf-content +List-Unsubscribe: https://www.example.com/unsubscribe +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="b1_5119e0e9531a692224d1bd44ef560e62" +Content-Transfer-Encoding: 8bit + +--D9E933966464.1593705905/tr2.example.com-- diff --git a/spec/sisimai/mail/maildir_spec.rb b/spec/sisimai/mail/maildir_spec.rb index 20e25697..384d6148 100644 --- a/spec/sisimai/mail/maildir_spec.rb +++ b/spec/sisimai/mail/maildir_spec.rb @@ -3,7 +3,7 @@ describe Sisimai::Mail::Maildir do samplemaildir = './set-of-emails/maildir/bsd' - allofthefiles = 489 + allofthefiles = 490 let(:mailobj) { Sisimai::Mail::Maildir.new(samples) } let(:mockobj) { Sisimai::Mail::Maildir.new(invalid) } diff --git a/spec/sisimai/rhost/cox_spec.rb b/spec/sisimai/rhost/cox_spec.rb new file mode 100644 index 00000000..f936c001 --- /dev/null +++ b/spec/sisimai/rhost/cox_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' +require 'sisimai/mail' +require 'sisimai/data' +require 'sisimai/message' +require 'sisimai/rhost/cox' + +describe Sisimai::Rhost::Cox do + rs = { + '01' => { 'status' => %r/\A5[.]1[.]0\z/, 'reason' => %r/blocked/ }, + } + describe 'bounce mail from Cox' do + rs.each_key.each do |n| + emailfn = sprintf('./set-of-emails/maildir/bsd/rhost-cox-%02d.eml', n) + next unless File.exist?(emailfn) + + mailbox = Sisimai::Mail.new(emailfn) + mtahost = /cox[.]net\z/ + next unless mailbox + + while r = mailbox.data.read do + mesg = Sisimai::Message.new(data: r) + it('is Sisimai::Message object') { expect(mesg).to be_a Sisimai::Message } + it('has array in "ds" accessor' ) { expect(mesg.ds).to be_a Array } + it('has hash in "header" accessor' ) { expect(mesg.header).to be_a Hash } + it('has hash in "rfc822" accessor' ) { expect(mesg.rfc822).to be_a Hash } + it('has From line in "from" accessor' ) { expect(mesg.from.size).to be > 0 } + + mesg.ds.each do |e| + example('spec is "SMTP"') { expect(e['spec']).to be == 'SMTP' } + example 'recipient is email address' do + expect(e['recipient']).to match(/\A.+[@].+[.].+\z/) + end + example('status is DSN') { expect(e['status']).to match(/\A\d[.]\d[.]\d\z/) } + example('command exists ') { expect(e['command']).to match(/\A(?:MAIL|)\z/) } + example('diagnosis is not empty') { expect(e['diagnosis']).not_to be_empty } + example('action is not empty') { expect(e['action']).not_to be_empty } + example('recipient ends with ' + mtahost.to_s) { expect(e['recipient']).to match(mtahost) } + example('alias exists') { expect(e['alias']).not_to be_nil } + example('agent is ' + e['agent']) { expect(e['agent']).to match(/\A(?:Postfix|RFC3464)/) } + end + + data = Sisimai::Data.make(data: mesg) + data.each do |e| + example('reason is String') { expect(e.reason.size).to be > 0 } + example('reason matches') { expect(e.reason).to match(rs[n]['reason']) } + end + end + end + + end +end +