File Coverage

lib/Sisimai/Lhost/Office365.pm
Criterion Covered Total %
statement 89 91 97.8
branch 59 66 89.3
condition 12 18 66.6
subroutine 6 6 100.0
pod 2 2 100.0
total 168 183 91.8


line stmt bran cond sub pod time code
1             package Sisimai::Lhost::Office365;
2 26     26   5412 use parent 'Sisimai::Lhost';
  26         54  
  26         146  
3 26     26   1602 use feature ':5.10';
  26         51  
  26         1804  
4 26     26   140 use strict;
  26         54  
  26         469  
5 26     26   111 use warnings;
  26         43  
  26         50077  
6              
7 2     2 1 982 sub description { 'Microsoft Office 365: https://office.microsoft.com/' }
8             sub make {
9             # Detect an error from Microsoft Office 365
10             # @param [Hash] mhead Message headers of a bounce email
11             # @param [String] mbody Message body of a bounce email
12             # @return [Hash] Bounce data list and message/rfc822 part
13             # @return [Undef] failed to parse or the arguments are missing
14             # @since v4.1.3
15 396     396 1 1104 my $class = shift;
16 396   100     1109 my $mhead = shift // return undef;
17 395   50     959 my $mbody = shift // return undef;
18 395         580 my $match = 0;
19 395         1351 my $tryto = qr/.+[.](?:outbound[.]protection|prod)[.]outlook[.]com\b/;
20              
21             # X-MS-Exchange-Message-Is-Ndr:
22             # X-Microsoft-Antispam-PRVS: <....@...outlook.com>
23             # X-Exchange-Antispam-Report-Test: UriScan:;
24             # X-Exchange-Antispam-Report-CFA-Test:
25             # X-MS-Exchange-CrossTenant-OriginalArrivalTime: 29 Apr 2015 23:34:45.6789 (JST)
26             # X-MS-Exchange-CrossTenant-FromEntityHeader: Hosted
27             # X-MS-Exchange-Transport-CrossTenantHeadersStamped: ...
28 395 100       1615 $match++ if index($mhead->{'subject'}, 'Undeliverable:') > -1;
29 395 50       1207 $match++ if $mhead->{'x-ms-exchange-message-is-ndr'};
30 395 100       1130 $match++ if $mhead->{'x-microsoft-antispam-prvs'};
31 395 100       1020 $match++ if $mhead->{'x-exchange-antispam-report-test'};
32 395 100       1042 $match++ if $mhead->{'x-exchange-antispam-report-cfa-test'};
33 395 100       937 $match++ if $mhead->{'x-ms-exchange-crosstenant-originalarrivaltime'};
34 395 100       987 $match++ if $mhead->{'x-ms-exchange-crosstenant-fromentityheader'};
35 395 100       874 $match++ if $mhead->{'x-ms-exchange-transport-crosstenantheadersstamped'};
36 395 100       686 $match++ if grep { $_ =~ $tryto } @{ $mhead->{'received'} };
  861         15967  
  395         1428  
37 395 100       1408 if( defined $mhead->{'message-id'} ) {
38             # Message-ID: <00000000-0000-0000-0000-000000000000@*.*.prod.outlook.com>
39 375 100       3068 $match++ if $mhead->{'message-id'} =~ $tryto;
40             }
41 395 100       1477 return undef if $match < 2;
42              
43 66         182 state $indicators = __PACKAGE__->INDICATORS;
44 66         125 state $rebackbone = qr|^Content-Type:[ ]message/rfc822|m;
45 66         146 state $markingsof = {
46             'eoe' => qr/\A(?:Original[ ][Mm]essage[ ][Hh]eaders:?|Message[ ]Hops)/,
47             'error' => qr/\A(?:Diagnostic[ ]information[ ]for[ ]administrators:|Error[ ]Details)/,
48             'message' => qr{\A(?:
49             Delivery[ ]has[ ]failed[ ]to[ ]these[ ]recipients[ ]or[ ]groups:
50             |Original[ ]Message[ ]Details
51             |.+[ ]rejected[ ]your[ ]message[ ]to[ ]the[ ]following[ ]e[-]?mail[ ]addresses:
52             )
53             }x,
54             };
55 66         310 state $statuslist = {
56             # https://support.office.com/en-us/article/Email-non-delivery-reports-in-Office-365-51daa6b9-2e35-49c4-a0c9-df85bf8533c3
57             qr/\A4[.]4[.]7\z/ => 'expired',
58             qr/\A4[.]4[.]312\z/ => 'networkerror',
59             qr/\A4[.]4[.]316\z/ => 'expired',
60             qr/\A4[.]7[.]26\z/ => 'securityerror',
61             qr/\A4[.]7[.][56]\d\d\z/ => 'blocked',
62             qr/\A4[.]7[.]8[5-9]\d\z/ => 'blocked',
63             qr/\A5[.]0[.]350\z/ => 'contenterror',
64             qr/\A5[.]1[.]10\z/ => 'userunknown',
65             qr/\A5[.]4[.]1\z/ => 'norelaying',
66             qr/\A5[.]4[.]6\z/ => 'networkerror',
67             qr/\A5[.]4[.]312\z/ => 'networkerror',
68             qr/\A5[.]4[.]316\z/ => 'expired',
69             qr/\A5[.]6[.]11\z/ => 'contenterror',
70             qr/\A5[.]7[.]1\z/ => 'rejected',
71             qr/\A5[.]7[.]1[23]\z/ => 'rejected',
72             qr/\A5[.]7[.]124\z/ => 'rejected',
73             qr/\A5[.]7[.]13[3-6]\z/ => 'rejected',
74             qr/\A5[.]7[.]23\z/ => 'blocked',
75             qr/\A5[.]7[.]25\z/ => 'networkerror',
76             qr/\A5[.]7[.]50[1-3]\z/ => 'spamdetected',
77             qr/\A5[.]7[.]50[4-5]\z/ => 'filtered',
78             qr/\A5[.]7[.]50[6-7]\z/ => 'blocked',
79             qr/\A5[.]7[.]508\z/ => 'toomanyconn',
80             qr/\A5[.]7[.]509\z/ => 'securityerror',
81             qr/\A5[.]7[.]510\z/ => 'notaccept',
82             qr/\A5[.]7[.]511\z/ => 'rejected',
83             qr/\A5[.]7[.]512\z/ => 'securityerror',
84             qr/\A5[.]7[.]57\z/ => 'securityerror',
85             qr/\A5[.]7[.]60[6-9]\z/ => 'blocked',
86             qr/\A5[.]7[.]6[1-4]\d\z/ => 'blocked',
87             qr/\A5[.]7[.]7[0-4]\d\z/ => 'toomanyconn',
88             };
89 66         143 state $recommands = {
90             'RCPT' => qr/unknown recipient or mailbox unavailable ->.+[<]?.+[@].+[.][a-zA-Z]+[>]?/,
91             };
92              
93 66         653 require Sisimai::RFC1894;
94 66         362 my $fieldtable = Sisimai::RFC1894->FIELDTABLE;
95 66         134 my $permessage = {}; # (Hash) Store values of each Per-Message field
96 66         246 my $dscontents = [__PACKAGE__->DELIVERYSTATUS];
97 66         345 my $emailsteak = Sisimai::RFC5322->fillet($mbody, $rebackbone);
98 66         171 my $readcursor = 0; # (Integer) Points the current cursor position
99 66         92 my $recipients = 0; # (Integer) The number of 'Final-Recipient' header
100 66         102 my $endoferror = 0; # (Integer) Flag for the end of error messages
101 66         113 my $v = undef;
102              
103 66         1866 for my $e ( split("\n", $emailsteak->[0]) ) {
104             # Read error messages and delivery status lines from the head of the email
105             # to the previous line of the beginning of the original message.
106 5571 100       7562 unless( $readcursor ) {
107             # Beginning of the bounce message or message/delivery-status part
108 327 100       1507 $readcursor |= $indicators->{'deliverystatus'} if $e =~ $markingsof->{'message'};
109 327         466 next;
110             }
111 5244 50       7603 next unless $readcursor & $indicators->{'deliverystatus'};
112 5244 100       6900 next unless length $e;
113              
114             # kijitora@example.com
115             # The email address wasn't found at the destination domain. It might
116             # be misspelled or it might not exist any longer. Try retyping the
117             # address and resending the message.
118             #
119             # Original Message Details
120             # Created Date: 4/29/2017 6:40:30 AM
121             # Sender Address: neko@example.jp
122             # Recipient Address: kijitora@example.org
123             # Subject: Nyaan
124 4464         4692 $v = $dscontents->[-1];
125 4464 100 100     14102 if( $e =~ /\A.+[@].+[<]mailto:(.+[@].+)[>]\z/ ||
    100          
126             $e =~ /\ARecipient[ ]Address:[ ]+(.+)\z/ ) {
127             # kijitora@example.com
128 66 50       205 if( $v->{'recipient'} ) {
129             # There are multiple recipient addresses in the message body.
130 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
131 0         0 $v = $dscontents->[-1];
132             }
133 66         223 $v->{'recipient'} = $1;
134 66         126 $recipients++;
135              
136             } elsif( $e =~ /\AGenerating server: (.+)\z/ ) {
137             # Generating server: FFFFFFFFFFFF.e0.prod.outlook.com
138 61         264 $permessage->{'lhost'} = lc $1;
139              
140             } else {
141 4337 100       5502 if( $endoferror ) {
142             # After "Original message headers:"
143 3624 100       5646 next unless my $f = Sisimai::RFC1894->match($e);
144 425 50       822 next unless my $o = Sisimai::RFC1894->field($e);
145 425 50       948 next unless exists $fieldtable->{ $o->[0] };
146 425 100       1189 next if $o->[0] =~ /\A(?:diagnostic-code|final-recipient)\z/;
147 315         597 $v->{ $fieldtable->{ $o->[0] } } = $o->[2];
148              
149 315 100       616 next unless $f == 1;
150 165         471 $permessage->{ $fieldtable->{ $o->[0] } } = $o->[2];
151              
152             } else {
153 713 100       2134 if( $e =~ $markingsof->{'error'} ) {
154             # Diagnostic information for administrators:
155 66         193 $v->{'diagnosis'} = $e;
156             } else {
157             # kijitora@example.com
158             # Remote Server returned '550 5.1.10 RESOLVER.ADR.RecipientNotFound; Recipien=
159             # t not found by SMTP address lookup'
160 647 100       1192 next unless $v->{'diagnosis'};
161 258 100       993 if( $e =~ $markingsof->{'eoe'} ) {
162             # Original message headers:
163 66         142 $endoferror = 1;
164 66         102 next;
165             }
166 192         538 $v->{'diagnosis'} .= ' '.$e;
167             }
168             }
169             }
170             }
171 66 50       597 return undef unless $recipients;
172              
173 66         162 for my $e ( @$dscontents ) {
174             # Set default values if each value is empty.
175 66   50     496 $e->{ $_ } ||= $permessage->{ $_ } || '' for keys %$permessage;
      66        
176 66         468 $e->{'diagnosis'} = Sisimai::String->sweep($e->{'diagnosis'});
177              
178 66 100 66     402 if( ! $e->{'status'} || substr($e->{'status'}, -4, 4) eq '.0.0' ) {
179             # There is no value of Status header or the value is 5.0.0, 4.0.0
180 11   33     69 $e->{'status'} = Sisimai::SMTP::Status->find($e->{'diagnosis'}) || $e->{'status'};
181             }
182              
183 66         231 for my $p ( keys %$recommands ) {
184             # Try to match with regular expressions defined in recommands
185 66 100       527 next unless $e->{'diagnosis'} =~ $recommands->{ $p };
186 15         39 $e->{'command'} = $p;
187 15         30 last;
188             }
189              
190             # Find the error code from $statuslist
191 66 50       221 next unless $e->{'status'};
192 66         609 for my $f ( keys %$statuslist ) {
193             # Try to match with each key as a regular expression
194 1659 100       22369 next unless $e->{'status'} =~ $f;
195 21         88 $e->{'reason'} = $statuslist->{ $f };
196 21         71 last;
197             }
198             }
199 66         656 return { 'ds' => $dscontents, 'rfc822' => $emailsteak->[1] };
200             }
201              
202             1;
203             __END__