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   5395 use parent 'Sisimai::Lhost';
  26         46  
  26         272  
3 26     26   1437 use feature ':5.10';
  26         45  
  26         1699  
4 26     26   123 use strict;
  26         38  
  26         518  
5 26     26   105 use warnings;
  26         42  
  26         43467  
6              
7 2     2 1 937 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 1100 my $class = shift;
16 396   100     965 my $mhead = shift // return undef;
17 395   50     933 my $mbody = shift // return undef;
18 395         565 my $match = 0;
19 395         1301 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       1266 $match++ if index($mhead->{'subject'}, 'Undeliverable:') > -1;
29 395 50       1010 $match++ if $mhead->{'x-ms-exchange-message-is-ndr'};
30 395 100       914 $match++ if $mhead->{'x-microsoft-antispam-prvs'};
31 395 100       777 $match++ if $mhead->{'x-exchange-antispam-report-test'};
32 395 100       749 $match++ if $mhead->{'x-exchange-antispam-report-cfa-test'};
33 395 100       838 $match++ if $mhead->{'x-ms-exchange-crosstenant-originalarrivaltime'};
34 395 100       851 $match++ if $mhead->{'x-ms-exchange-crosstenant-fromentityheader'};
35 395 100       814 $match++ if $mhead->{'x-ms-exchange-transport-crosstenantheadersstamped'};
36 395 100       507 $match++ if grep { $_ =~ $tryto } @{ $mhead->{'received'} };
  861         13548  
  395         1013  
37 395 100       1079 if( defined $mhead->{'message-id'} ) {
38             # Message-ID: <00000000-0000-0000-0000-000000000000@*.*.prod.outlook.com>
39 375 100       2653 $match++ if $mhead->{'message-id'} =~ $tryto;
40             }
41 395 100       1373 return undef if $match < 2;
42              
43 66         120 state $indicators = __PACKAGE__->INDICATORS;
44 66         99 state $rebackbone = qr|^Content-Type:[ ]message/rfc822|m;
45 66         142 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         249 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         115 state $recommands = {
90             'RCPT' => qr/unknown recipient or mailbox unavailable ->.+[<]?.+[@].+[.][a-zA-Z]+[>]?/,
91             };
92              
93 66         604 require Sisimai::RFC1894;
94 66         327 my $fieldtable = Sisimai::RFC1894->FIELDTABLE;
95 66         115 my $permessage = {}; # (Hash) Store values of each Per-Message field
96 66         239 my $dscontents = [__PACKAGE__->DELIVERYSTATUS];
97 66         358 my $emailsteak = Sisimai::RFC5322->fillet($mbody, $rebackbone);
98 66         105 my $readcursor = 0; # (Integer) Points the current cursor position
99 66         147 my $recipients = 0; # (Integer) The number of 'Final-Recipient' header
100 66         116 my $endoferror = 0; # (Integer) Flag for the end of error messages
101 66         75 my $v = undef;
102              
103 66         1609 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       6301 unless( $readcursor ) {
107             # Beginning of the bounce message or message/delivery-status part
108 327 100       1254 $readcursor |= $indicators->{'deliverystatus'} if $e =~ $markingsof->{'message'};
109 327         394 next;
110             }
111 5244 50       6749 next unless $readcursor & $indicators->{'deliverystatus'};
112 5244 100       5768 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         4140 $v = $dscontents->[-1];
125 4464 100 100     11443 if( $e =~ /\A.+[@].+[<]mailto:(.+[@].+)[>]\z/ ||
    100          
126             $e =~ /\ARecipient[ ]Address:[ ]+(.+)\z/ ) {
127             # kijitora@example.com
128 66 50       163 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         201 $v->{'recipient'} = $1;
134 66         106 $recipients++;
135              
136             } elsif( $e =~ /\AGenerating server: (.+)\z/ ) {
137             # Generating server: FFFFFFFFFFFF.e0.prod.outlook.com
138 61         215 $permessage->{'lhost'} = lc $1;
139              
140             } else {
141 4337 100       4768 if( $endoferror ) {
142             # After "Original message headers:"
143 3624 100       5041 next unless my $f = Sisimai::RFC1894->match($e);
144 425 50       636 next unless my $o = Sisimai::RFC1894->field($e);
145 425 50       1089 next unless exists $fieldtable->{ $o->[0] };
146 425 100       873 next if $o->[0] =~ /\A(?:diagnostic-code|final-recipient)\z/;
147 315         579 $v->{ $fieldtable->{ $o->[0] } } = $o->[2];
148              
149 315 100       577 next unless $f == 1;
150 165         445 $permessage->{ $fieldtable->{ $o->[0] } } = $o->[2];
151              
152             } else {
153 713 100       1770 if( $e =~ $markingsof->{'error'} ) {
154             # Diagnostic information for administrators:
155 66         116 $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       960 next unless $v->{'diagnosis'};
161 258 100       794 if( $e =~ $markingsof->{'eoe'} ) {
162             # Original message headers:
163 66         94 $endoferror = 1;
164 66         91 next;
165             }
166 192         458 $v->{'diagnosis'} .= ' '.$e;
167             }
168             }
169             }
170             }
171 66 50       628 return undef unless $recipients;
172              
173 66         148 for my $e ( @$dscontents ) {
174             # Set default values if each value is empty.
175 66   50     429 $e->{ $_ } ||= $permessage->{ $_ } || '' for keys %$permessage;
      66        
176 66         509 $e->{'diagnosis'} = Sisimai::String->sweep($e->{'diagnosis'});
177              
178 66 100 66     341 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     53 $e->{'status'} = Sisimai::SMTP::Status->find($e->{'diagnosis'}) || $e->{'status'};
181             }
182              
183 66         208 for my $p ( keys %$recommands ) {
184             # Try to match with regular expressions defined in recommands
185 66 100       454 next unless $e->{'diagnosis'} =~ $recommands->{ $p };
186 15         35 $e->{'command'} = $p;
187 15         28 last;
188             }
189              
190             # Find the error code from $statuslist
191 66 50       180 next unless $e->{'status'};
192 66         473 for my $f ( keys %$statuslist ) {
193             # Try to match with each key as a regular expression
194 1870 100       22281 next unless $e->{'status'} =~ $f;
195 21         78 $e->{'reason'} = $statuslist->{ $f };
196 21         72 last;
197             }
198             }
199 66         628 return { 'ds' => $dscontents, 'rfc822' => $emailsteak->[1] };
200             }
201              
202             1;
203             __END__