File Coverage

lib/Sisimai/Fact.pm
Criterion Covered Total %
statement 303 305 99.3
branch 143 164 87.2
condition 125 159 78.6
subroutine 24 24 100.0
pod 3 5 60.0
total 598 657 91.0


line stmt bran cond sub pod time code
1             package Sisimai::Fact;
2 84     84   516128 use v5.26;
  84         290  
3 84     84   404 use strict;
  84         117  
  84         1964  
4 84     84   332 use warnings;
  84         117  
  84         4081  
5 84     84   49875 use Digest::SHA;
  84         318173  
  84         6754  
6 84     84   42335 use Sisimai::Message;
  84         350  
  84         3886  
7 84     84   641 use Sisimai::RFC791;
  84         145  
  84         2001  
8 84     84   405 use Sisimai::RFC1123;
  84         130  
  84         1588  
9 84     84   378 use Sisimai::RFC1894;
  84         135  
  84         1424  
10 84     84   327 use Sisimai::RFC5322;
  84         134  
  84         1440  
11 84     84   48101 use Sisimai::Reason;
  84         255  
  84         3777  
12 84     84   619 use Sisimai::Address;
  84         148  
  84         2364  
13 84     84   45186 use Sisimai::DateTime;
  84         1515  
  84         4414  
14 84     84   60878 use Sisimai::Time;
  84         324  
  84         1053  
15 84     84   46814 use Sisimai::SMTP::Command;
  84         1366  
  84         4164  
16 84     84   42440 use Sisimai::SMTP::Failure;
  84         296  
  84         3653  
17 84     84   583 use Sisimai::String;
  84         146  
  84         2054  
18 84     84   41523 use Sisimai::Rhost;
  84         244  
  84         3352  
19 84     84   37109 use Sisimai::LDA;
  84         252  
  84         7588  
20 84         1123 use Class::Accessor::Lite ('new' => 0, 'rw' => [
21             'action', # [String] The value of Action: header
22             'addresser', # [Sisimai::Address] From address
23             'alias', # [String] Alias of the recipient address
24             'catch', # [?] Results generated by hook method
25             'command', # [String] The last SMTP command
26             'decodedby', # [String] MTA Module name since v5.2.0
27             'deliverystatus', # [String] Delivery Status(DSN)
28             'destination', # [String] The domain part of the "recipient"
29             'diagnosticcode', # [String] Diagnostic-Code: Header
30             'diagnostictype', # [String] The 1st part of Diagnostic-Code: Header
31             'feedbackid', # [String] The value of Feedback-ID: header of the original message
32             'feedbacktype', # [String] Feedback Type
33             'hardbounce', # [Integer] 1 = Hard bounce, 0 = Is not a hard bounce
34             'lhost', # [String] local host name/Local MTA
35             'listid', # [String] List-Id header of each ML
36             'messageid', # [String] Message-Id: header
37             'origin', # [String] Email path as a data source
38             'reason', # [String] Bounce reason
39             'recipient', # [Sisimai::Address] Recipient address which bounced
40             'replycode', # [String] SMTP Reply Code
41             'rhost', # [String] Remote host name/Remote MTA
42             'senderdomain', # [String] The domain part of the "addresser"
43             'subject', # [String] UTF-8 Subject text
44             'timestamp', # [Sisimai::Time] Date: header in the original message
45             'timezoneoffset', # [Integer] Time zone offset(seconds)
46             'token', # [String] Message token/MD5 Hex digest value
47             'toxic', # EXPERIMENTAL
48 84     84   571 ]);
  84         159  
49              
50             sub rise {
51             # Constructor of Sisimai::Fact
52             # @param [Hash] argvs
53             # @options argvs [String] data Entire email message
54             # @options argvs [Integer] delivered 1 if the result which has "delivered" reason is included
55             # @options argvs [Integer] vacation 1 if the result which has "vacation" reason is included
56             # @options argvs [Code] hook Code reference to callback method
57             # @options argvs [String] origin Path to the original email file
58             # @return [Array] Array of Sisimai::Fact objects
59 3502     3502 1 381457 my $class = shift;
60 3502   100     10923 my $argvs = shift || return undef;
61 3501 50       14361 die ' ***error: Sisimai::Fact->rise receives only a HASH reference as an argument' unless ref $argvs eq 'HASH';
62              
63 3501   100     12235 my $email = $argvs->{'data'} || return undef;
64 3500         14353 my $args1 = {'data' => $email, 'hook' => $argvs->{'hook'}};
65 3500   100     28038 my $mesg1 = Sisimai::Message->rise($args1) || return undef;
66              
67 3456 50       12804 return undef unless $mesg1->{'ds'};
68 3456 50       9878 return undef unless $mesg1->{'rfc822'};
69              
70 3456         9261 state $retryindex = Sisimai::Reason->retry;
71 3456         6538 state $rfc822head = Sisimai::RFC5322::HEADERTABLE;
72 3456         7259 state $actionlist = {'delayed' => 1, 'delivered' => 1, 'expanded' => 1, 'failed' => 1, 'relayed' => 1};
73 3456         6448 my $rfc822data = $mesg1->{'rfc822'};
74 3456         6736 my $listoffact = [];
75              
76 3456         10782 RISEOF: for my $e ( $mesg1->{'ds'}->@* ) {
77             # Create parameters
78 3638 50       12457 next if length $e->{'recipient'} < 5;
79 3638 100 100     22183 next if ! $argvs->{'delivered'} && index($e->{'status'}, '2.') == 0;
80 3626 100 100     17415 next if ! $argvs->{'vacation'} && $e->{'reason'} eq 'vacation';
81              
82 3605         6750 my $thing = {}; # To be blessed and pushed into the array above at the end of the loop
83             my $piece = {
84             'action' => $e->{'action'} // '',
85             'alias' => $e->{'alias'} // '',
86             'catch' => $mesg1->{'catch'} // undef,
87             'decodedby' => $e->{'agent'} // '',
88             'deliverystatus' => $e->{'status'} // '',
89             'diagnosticcode' => $e->{'diagnosis'} // '',
90             'diagnostictype' => $e->{'spec'} // '',
91             'feedbacktype' => $e->{'feedbacktype'} // '',
92             'hardbounce' => 0,
93             'lhost' => $e->{'lhost'} // '',
94             'origin' => $argvs->{'origin'} // '',
95             'reason' => $e->{'reason'} // '',
96             'recipient' => $e->{'recipient'} // '',
97             'replycode' => $e->{'replycode'} // '',
98             'rhost' => $e->{'rhost'} // '',
99             'command' => $e->{'command'} // '',
100 3605   50     131602 'toxic' => $e->{'toxic'} // 0,
      50        
      100        
      50        
      50        
      50        
      50        
      50        
      100        
      100        
      50        
      50        
      50        
      50        
      50        
      50        
101             };
102              
103             ADDRESSER: {
104             # Detect an email address from message/rfc822 part
105 3605         10293 my $j = [];
  3605         5864  
106 3605         11269 for my $f ( $rfc822head->{'addresser'}->@* ) {
107             # Check each header in message/rfc822 part
108 5634 100       14819 next unless exists $rfc822data->{ $f };
109 3333 50       10687 next unless $rfc822data->{ $f };
110              
111 3333   50     24607 $j = Sisimai::Address->find($rfc822data->{ $f }) || next;
112 3333         15493 $piece->{'addresser'} = shift @$j;
113 3333         9579 last ADDRESSER;
114             }
115              
116             # Fallback: Get the sender address from the header of the bounced email if the address
117             # is not set at the loop above.
118 272   50     2444 $j = Sisimai::Address->find($mesg1->{'header'}->{'to'}) || [];
119 272         1255 $piece->{'addresser'} = shift @$j;
120             }
121 3605 50       9998 next RISEOF unless $piece->{'addresser'};
122              
123             TIMESTAMP: {
124             # Convert from a time stamp or a date string to a machine time.
125 3605         8196 my $datestring = undef;
  3605         6654  
126 3605         6651 my $zoneoffset = 0;
127 3605 100       5471 my @datevalues; push @datevalues, $e->{'date'} if $e->{'date'};
  3605         15007  
128              
129             # Date information did not exist in message/delivery-status part,...
130 3605         10505 for my $f ( $rfc822head->{'date'}->@* ) {
131             # Get the value of Date header or other date related header.
132 14420 100       34640 next unless $rfc822data->{ $f };
133 3139         8141 push @datevalues, $rfc822data->{ $f };
134             }
135              
136             # Set "date" getting from the value of "Date" in the bounce message
137 3605 100       16417 push @datevalues, $mesg1->{'header'}->{'date'} if scalar(@datevalues) < 2;
138              
139 3605         13350 while( my $v = shift @datevalues ) {
140             # Decode each date value in the array
141 3605   50     36312 $datestring = Sisimai::DateTime->parse($v) || next;
142              
143 3605 50       26080 if( $datestring =~ /\A(.+)[ ]+([-+]\d{4})\z/ ) {
144             # Get the value of timezone offset from $datestring: Wed, 26 Feb 2014 06:05:48 -0500
145 3605         11180 $datestring = $1;
146 3605         19660 $zoneoffset = Sisimai::DateTime->tz2second($2);
147 3605         13857 $piece->{'timezoneoffset'} = $2;
148             }
149 3605 50       10822 last if $datestring;
150             }
151              
152 3605         7925 eval {
153             # Convert from the date string to an object then calculate time zone offset.
154 3605         38773 my $t = Sisimai::Time->strptime($datestring, '%a, %d %b %Y %T');
155 3605   50     217611 $piece->{'timestamp'} = ($t->epoch - $zoneoffset) // undef;
156             };
157             }
158 3605 50       293613 next RISEOF unless defined $piece->{'timestamp'};
159              
160             RECEIVED: {
161             # Scan "Received:" header of the original message
162 3605   50     6902 my $recv = $mesg1->{'header'}->{'received'} || [];
  3605         15970  
163 3605 100       11244 unless( $piece->{'rhost'} ) {
164             # Try to pick a remote hostname from Received: headers of the bounce message
165 1655         15884 my $ir = Sisimai::RFC1123->find($e->{'diagnosis'});
166 1655 100       6902 $piece->{'rhost'} = $ir if Sisimai::RFC1123->is_internethost($ir);
167              
168 1655 100       6019 unless( $piece->{'rhost'} ) {
169             # The remote hostname in the error message did not exist or is not a valid
170             # internet hostname
171 1239         3180 for my $re ( reverse @$recv ) {
172             # Check the Received: headers backwards and get a remote hostname
173 1933 100       8459 last if $piece->{'rhost'};
174 1509         7041 my $cv = Sisimai::RFC5322->received($re)->[0];
175 1509 100       7250 next unless Sisimai::RFC1123->is_internethost($cv);
176 1091         4495 $piece->{'rhost'} = $cv;
177             }
178             }
179             }
180 3605 100       15403 $piece->{'lhost'} = '' if $piece->{'lhost'} eq $piece->{'rhost'};
181              
182 3605 100       11068 unless( $piece->{'lhost'} ) {
183             # Try to pick a local hostname from Received: headers of the bounce message
184 1568         4038 for my $le ( @$recv ) {
185             # Check the Received: headers forwards and get a local hostname
186 1761         7765 my $cv = Sisimai::RFC5322->received($le)->[0];
187 1761 100       10690 next unless Sisimai::RFC1123->is_internethost($cv);
188 1167         7811 $piece->{'lhost'} = $cv; last;
  1167         2791  
189             }
190             }
191              
192 3605         8122 for my $v ('rhost', 'lhost') {
193             # Check and rewrite each host name
194 7210 100       19795 next unless length $piece->{ $v };
195 6661 50       21712 if( index($piece->{ $v }, '@') > -1 ) {
196             # Use the domain part as a remote/local host when the value is an email address
197 0         0 $piece->{ $v } = (split('@', $piece->{ $v }))[-1];
198             }
199 6661         25158 y/[]()\r//d, s/\A.+=// for $piece->{ $v }; # Remove [], (), \r, and strings before "="
200              
201 6661 100       18810 if( index($piece->{ $v }, ' ') > -1 ) {
202             # Check a space character in each value and get the first hostname
203 77         274 my @ee = split(' ', $piece->{ $v });
204 77         210 for my $w ( @ee ) {
205             # get a hostname from the string like "127.0.0.1 x109-20.example.com 192.0.2.20"
206             # or "mx.sp.example.jp 192.0.2.135"
207 77 50       542 next if Sisimai::RFC791->is_ipv4address($w);
208 77         171 $piece->{ $v } = $w; last;
  77         145  
209             }
210 77 50       300 $piece->{ $v } = $ee[0] if index($piece->{ $v }, ' ') > 0;
211             }
212 6661 100       21306 chop $piece->{ $v } if substr($piece->{ $v }, -1, 1) eq '.'; # Remove "." at the end of the value
213             }
214             }
215              
216             ID_HEADERS: {
217             # Message-ID:, List-ID: headers of the original message
218 3605         6161 my $p0 = 0;
  3605         5889  
219 3605         6837 my $p1 = 0;
220 3605 100       27172 if( Sisimai::String->aligned(\$rfc822data->{'message-id'}, ['<', '@', '>']) ) {
221             # https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4
222             # Leave only string inside of angle brackets(<>)
223 3062         8349 $p0 = index($rfc822data->{'message-id'}, '<') + 1;
224 3062         7154 $p1 = index($rfc822data->{'message-id'}, '>');
225 3062         13079 $piece->{'messageid'} = substr($rfc822data->{'message-id'}, $p0, $p1 - $p0);
226              
227             } else {
228             # Invalid value of the Message-Id: field
229 543         2013 $piece->{'messageid'} = '';
230             }
231              
232 3605 100       21533 if( Sisimai::String->aligned(\$rfc822data->{'list-id'}, ['<', '.', '>']) ) {
233             # https://www.rfc-editor.org/rfc/rfc2919
234             # Get the value of List-Id header: "List name "
235 27         93 $p0 = index($rfc822data->{'list-id'}, '<') + 1;
236 27         115 $p1 = index($rfc822data->{'list-id'}, '>');
237 27         184 $piece->{'listid'} = substr($rfc822data->{'list-id'}, $p0, $p1 - $p0);
238              
239             } else {
240             # Invalid value of the List-Id: field
241 3578         13967 $piece->{'listid'} = '';
242             }
243             }
244              
245             DIAGNOSTICCODE: {
246             # Cleanup the value of "Diagnostic-Code:" header
247 3605 100       7376 last unless length $piece->{'diagnosticcode'};
  3605         9941  
248              
249             # Get an SMTP Reply Code and an SMTP Enhanced Status Code
250 3595 50       13061 chop $piece->{'diagnosticcode'} if substr($piece->{'diagnosticcode'}, -1, 1) eq "\r";
251              
252 3595   100     25338 my $cs = Sisimai::SMTP::Status->find($piece->{'diagnosticcode'}) || '';
253 3595   100     27834 my $cr = Sisimai::SMTP::Reply->find( $piece->{'diagnosticcode'}, $cs) || '';
254 3595         20355 $piece->{'deliverystatus'} = Sisimai::SMTP::Status->prefer($piece->{'deliverystatus'}, $cs, $cr);
255              
256 3595 100       12548 if( length $cr == 3 ) {
257             # There is an SMTP reply code in the error message
258 2585   66     12494 $piece->{'replycode'} ||= $cr;
259              
260 2585 100       10622 if( index($piece->{'diagnosticcode'}, $cr.'-') > -1 ) {
261             # 550-5.7.1 [192.0.2.222] Our system has detected that this message is
262             # 550-5.7.1 likely unsolicited mail. To reduce the amount of spam sent to Gmail,
263             # 550-5.7.1 this message has been blocked. Please visit
264             # 550 5.7.1 https://support.google.com/mail/answer/188131 for more information.
265             #
266             # kijitora@example.co.uk
267             # host c.eu.example.com [192.0.2.3]
268             # SMTP error from remote mail server after end of data:
269             # 553-SPF (Sender Policy Framework) domain authentication
270             # 553-fail. Refer to the Troubleshooting page at
271             # 553-http://www.symanteccloud.com/troubleshooting for more
272             # 553 information. (#5.7.1)
273 207         666 for my $q ('-', ' ') {
274             # Remove strings: "550-5.7.1", and "550 5.7.1" from the error message
275 414         809 my $cx = sprintf("%s%s%s", $cr, $q, $cs);
276 414         935 my $p0 = index($piece->{'diagnosticcode'}, $cx);
277 414         1029 while( $p0 > -1 ) {
278             # Remove strings like "550-5.7.1"
279 801         1541 substr($piece->{'diagnosticcode'}, $p0, length $cx, '');
280 801         1799 $p0 = index($piece->{'diagnosticcode'}, $cx);
281             }
282              
283             # Remove "553-" and "553 " (SMTP reply code only) from the error message
284 414         719 $cx = sprintf("%s%s", $cr, $q);
285 414         2645 $p0 = index($piece->{'diagnosticcode'}, $cx);
286 414         1146 while( $p0 > -1 ) {
287             # Remove strings like "553-"
288 42         101 substr($piece->{'diagnosticcode'}, $p0, length $cx, '');
289 42         117 $p0 = index($piece->{'diagnosticcode'}, $cx);
290             }
291             }
292              
293 207 50       837 if( index($piece->{'diagnosticcode'}, $cr) > 1 ) {
294             # Add "550 5.1.1" into the head of the error message when the error
295             # message does not begin with "550"
296 0         0 $piece->{'diagnosticcode'} = sprintf("%s %s %s", $cr, $cs, $piece->{'diagnosticcode'});
297             }
298             }
299             }
300              
301 3595         12823 my $dc = lc $piece->{'diagnosticcode'};
302 3595         9115 my $p1 = index($dc, '');
303 3595         9220 my $p2 = index($dc, '');
304 3595 100 66     13286 substr($piece->{'diagnosticcode'}, $p1, $p2 + 7 - $p1, '') if $p1 > 0 && $p2 > 0;
305 3595         18571 $piece->{'diagnosticcode'} = Sisimai::String->sweep($piece->{'diagnosticcode'});
306             }
307              
308             DIAGNOSTICTYPE: {
309             # Set the value of "diagnostictype" if it is empty
310 3605 100 100     6269 $piece->{'diagnostictype'} ||= 'X-UNIX' if $piece->{'reason'} eq 'mailererror';
  3605         12294  
311 3605 100 100     8889 $piece->{'diagnostictype'} ||= 'SMTP' unless grep { $piece->{'reason'} eq $_ } ('feedback', 'vacation');
  7210         36350  
312             }
313              
314             # Check the SMTP command, the Subject field of the original message
315 3605 100       21581 $piece->{'command'} = '' unless Sisimai::SMTP::Command->test($piece->{'command'});
316 3605   50     15927 $piece->{'subject'} = $rfc822data->{'subject'} // '';
317 3605 50       13713 chop $piece->{'subject'} if substr($piece->{'subject'}, -1, 1) eq "\r";
318              
319             CONSTRUCTOR: {
320             # Create email address object
321 3605   50     5669 my $as = Sisimai::Address->new($piece->{'addresser'}) || next RISEOF;
  3605         19639  
322 3605   50     22385 my $ar = Sisimai::Address->new({'address' => $piece->{'recipient'}}) || next RISEOF;
323 3605         27331 my @ea = (qw|
324             action command decodedby deliverystatus diagnosticcode diagnostictype feedbacktype
325             lhost listid messageid origin reason replycode rhost subject
326             |);
327              
328             $thing = {
329             'addresser' => $as,
330             'recipient' => $ar,
331             'senderdomain' => $as->host,
332             'destination' => $ar->host,
333             'alias' => $piece->{'alias'} || $ar->alias,
334 3605   66     20680 'token' => __PACKAGE__->maketoken($as->address, $ar->address, $piece->{'timestamp'}),
335             };
336 3605   50     180104 $thing->{ $_ } ||= $piece->{ $_ } // '' for @ea;
      66        
337 3605   100     21617 $thing->{'catch'} = $piece->{'catch'} // undef;
338 3605         20509 $thing->{"feedbackid"} = "";
339 3605         10664 $thing->{'hardbounce'} = int $piece->{'hardbounce'};
340 3605         10392 $thing->{'toxic'} = int $piece->{'toxic'};
341 3605   100     21230 $thing->{'replycode'} ||= Sisimai::SMTP::Reply->find($piece->{'diagnosticcode'}) || '';
      100        
342 3605         22174 $thing->{'timestamp'} = Sisimai::Time->new($piece->{'timestamp'});
343 3605   50     410735 $thing->{'timezoneoffset'} = $piece->{'timezoneoffset'} // '+0000';
344             }
345              
346             ALIAS: {
347             # Look up the Envelope-To address from the Received: header in the original message
348             # when the recipient address is same with the value of $o->{'alias'}.
349 3605 100 100     6497 last if length $thing->{'alias'} == 0 || $thing->{'recipient'}->address ne $thing->{'alias'};
  3605         20588  
350 676 50       10910 last unless exists $rfc822data->{'received'};
351 676 100       2625 last unless scalar $rfc822data->{'received'}->@*;
352              
353 577         1803 for my $er ( reverse $rfc822data->{'received'}->@* ) {
354             # Search for the string " for " from the Received: header
355 1011 100       6836 next unless index($er, ' for ') > 1;
356 761         4653 my $or = Sisimai::RFC5322->received($er);
357              
358 761 50 33     4434 next if scalar(@$or) == 0 || length($or->[5]) == 0;
359 761 50       3297 next if Sisimai::Address->is_emailaddress($or->[5]) == 0;
360 761 100       4684 next if $thing->{'recipient'}->address eq $or->[5];
361              
362 33         387 $thing->{'alias'} = $or->[5];
363 33         150 last ALIAS;
364             }
365             }
366 3605 100       22356 $thing->{'alias'} = '' if $thing->{'alias'} eq $thing->{'recipient'}->{'address'};
367              
368             REASON: {
369             # Decide the reason of the email bounce
370 3605 100 100     5679 if( $thing->{'reason'} eq '' || exists $retryindex->{ $thing->{'reason'} } ) {
  3605         39070  
371             # The value of "reason" is empty or is needed to check with other values again
372 3262   100     15458 my $re = $thing->{'reason'} || 'undefined';
373 3262         6162 my $cr = "Sisimai::Reason";
374 3262 100       32479 my $or = Sisimai::LDA->find($thing); if( $cr->is_explicit($or) ){ $thing->{'reason'} = $or; last }
  3262         27094  
  32         72  
  32         81  
375 3230 100       28817 $or = Sisimai::Rhost->find($thing); if( $cr->is_explicit($or) ){ $thing->{'reason'} = $or; last }
  3230         13309  
  833         2550  
  833         2441  
376 2397 100       9594 $or = Sisimai::Reason->find($thing); if( $cr->is_explicit($or) ){ $thing->{'reason'} = $or; last }
  2397         9148  
  2360         7097  
  2360         6672  
377 37 100       209 $thing->{'reason'} = $thing->{'diagnosticcode'} ? "onhold" : $re;
378             }
379             }
380              
381             HARDBOUNCE: {
382             # Set the value of "hardbounce", default value of "bouncebounce" is 0
383 3605 100 100     6397 if( $thing->{'reason'} eq 'delivered' || $thing->{'reason'} eq 'feedback' || $thing->{'reason'} eq 'vacation' ) {
  3605   100     40070  
384             # Delete the value of ReplyCode when the Reason is "feedback" or "vacation"
385 120 100       616 $thing->{'replycode'} = '' unless $thing->{'reason'} eq 'delivered';
386              
387             } else {
388             # The reason is not "delivered", or "feedback", or "vacation"
389 3485         13300 my $smtperrors = $piece->{'deliverystatus'}.' '.$piece->{'diagnosticcode'};
390 3485 100       9409 $smtperrors = '' if length $smtperrors < 4;
391 3485         36639 $thing->{'hardbounce'} = Sisimai::SMTP::Failure->is_hardbounce($thing->{'reason'}, $smtperrors);
392             }
393             }
394              
395             DELIVERYSTATUS: {
396             # Set pseudo status code
397 3605 100       6590 last DELIVERYSTATUS if $thing->{'deliverystatus'};
  3605         12893  
398              
399 771         2622 my $smtperrors = $thing->{'replycode'}.' '.$piece->{'diagnosticcode'};
400 771 100       2658 $smtperrors = '' if length $smtperrors < 4;
401 771         3922 my $permanent0 = Sisimai::SMTP::Failure->is_permanent($smtperrors);
402 771         3450 my $temporary0 = Sisimai::SMTP::Failure->is_temporary($smtperrors);
403 771 100       3670 my $temporary1 = $permanent0.$temporary0 eq "00" ? 0 : $temporary0;
404 771         4912 $thing->{'deliverystatus'} = Sisimai::SMTP::Status->code($thing->{'reason'}, $temporary1);
405             }
406              
407             REPLYCODE: {
408             # Check both of the first digit of "deliverystatus" and "replycode"
409 3605         6088 my $cx = [substr($thing->{'deliverystatus'}, 0, 1), substr($thing->{'replycode'}, 0, 1)];
  3605         19859  
410 3605 100       12316 if( $cx->[0] ne $cx->[1] ) {
411             # The class of the "Status:" is defer with the first digit of the reply code
412 935   100     4657 $cx->[1] = Sisimai::SMTP::Reply->find($piece->{'diagnosticcode'}, $cx->[0]) || '';
413 935 100       5314 $thing->{'replycode'} = index($cx->[1], $cx->[0]) == 0 ? $cx->[1] : '';
414             }
415              
416 3605 100       17203 unless( exists $actionlist->{ $thing->{'action'} } ) {
417             # There is an action value which is not described at RFC1894
418 1665 50       17680 if( my $ox = Sisimai::RFC1894->field('Action: '.$thing->{'action'}) ) {
419             # Rewrite the value of "Action:" field to the valid value
420             #
421             # The syntax for the action-field is:
422             # action-field = "Action" ":" action-value
423             # action-value = "failed" / "delayed" / "delivered" / "relayed" / "expanded"
424 1665         4571 $thing->{'action'} = $ox->[2];
425             }
426             }
427 3605 100       12342 $thing->{'action'} = 'delivered' if $thing->{'reason'} eq 'delivered';
428 3605 100 100     12845 $thing->{'action'} ||= 'delayed' if $thing->{'reason'} eq 'expired';
429 3605 100 100     27868 $thing->{'action'} ||= 'failed' if $cx->[0] eq '4' || $cx->[0] eq '5';
      100        
430 3605   100     14266 $thing->{'action'} ||= "";
431             }
432              
433 3605 100       11843 if( $thing->{'replycode'} ne "" ) {
434             # Fill empty values: ["SMTP Command", "DSN", "Reason"]
435 2564         16148 my $cv = Sisimai::SMTP::Reply->associatedwith($thing->{'replycode'});
436 2564 100       8097 if( scalar @$cv > 0 ) {
437 324 100 100     1518 $thing->{'command'} = $cv->[0] if $cv->[0] ne "" && $thing->{'command'} eq "";
438 324 50 66     1518 $thing->{'deliverystatus'} = $cv->[1] if $cv->[1] ne "" && Sisimai::SMTP::Status->is_explicit($thing->{'deliverystatus'}) == 0;
439 324 100 66     2067 $thing->{'reason'} = $cv->[2] if $cv->[2] ne "" && Sisimai::Reason->is_explicit($thing->{'reason'}) == 0;
440             }
441             }
442             # Feedback-ID: 1.us-west-2.QHuyeCQrGtIIMGKQfVdUhP9hCQR2LglVOrRamBc+Prk=:AmazonSES
443 3605   100     19465 $thing->{'feedbackid'} = $rfc822data->{'feedback-id'} || "";
444 3605   100     26317 $thing->{'toxic'} ||= __PACKAGE__->is_toxic($thing);
445              
446 3605         47174 push @$listoffact, bless($thing, __PACKAGE__);
447             } # End of for(RISEOF)
448              
449 3456         91127 return $listoffact;
450             }
451              
452             sub is_toxic {
453             # is_toxic checks if the recipient address should be permanently excluded from the list.
454             # It returns true for addresses that pose a persistent delivery risk, making further resend
455             # attempts unviable and detrimental to the sender's reputation.
456             # @return [Bool] 1 if the recipient address should be removed from the list.
457 3599     3599 1 7449 my $class = shift;
458 3599   50     10535 my $thing = shift // return 0;
459 3599   50     11464 my $cr = $thing->{'reason'} || 'undefined';
460 3599   100     12338 my $cv = $thing->{'replycode'} || '';
461 3599   100     13779 my $cw = $thing->{'deliverystatus'} || '';
462              
463             # 1. Hard bounces or some soft bounces with a permanent error.
464             # 1-1. Hard bounce: UserUnknown, HostUnknown, HasMoved, NotAccept
465             # 1-2. Almost hard bounce: Suspend, Suppressed
466 3599 100 100     20998 return 0 if index($cv, '4') == 0 || index($cw, '4') == 0;
467 3138 100       7301 return 1 if grep { $cr eq $_ } qw[userunknown hostunknown hasmoved notaccept suspend suppressed];
  18828         37993  
468              
469 1901 100       3895 if( grep { $cr eq $_ } qw[mailboxfull filtered norelaying] ) {
  5703 100       14751  
470             # 2. Several softbounces: MailboxFull, Filtered, NoRelaying
471             # 2-1. The SMTP command is "RCPT" except "MailboxFull".
472             # 2-2. The SMTP reply code begins with "5" such as "550".
473             # 2-3. The SMTP status code is explicit code (not empty, not 5.0.9XX).
474             # 2-4. The SMTP status code begins with "5." such as "5.1.1".
475 580 100 100     3285 return 1 if $cr ne 'mailboxfull' && $thing->{'command'} eq 'RCPT';
476 565 100       2990 return 1 if index($cv, '5') == 0;
477 116 100       749 return 0 if Sisimai::SMTP::Status->is_explicit($cw) == 0;
478 36 50       203 return 1 if index($cw, '5.') == 0;
479              
480             } elsif( $cr eq 'feedback' ) {
481             # 3. Feedback Loop
482             # 3-1. The Feedback Type is any of "abuse", "fraud", "opt-out"
483 111 100       227 return 1 if grep { $thing->{'feedbacktype'} eq $_ } qw[abuse fraud opt-out];
  333         1048  
484             }
485 1225         4289 return 0;
486             }
487              
488             sub maketoken {
489             # Create the message token from an addresser and a recipient
490             # @param [String] addr1 A sender's email address
491             # @param [String] addr2 A recipient's email address
492             # @param [Integer] epoch Machine time of the email bounce
493             # @return [String] Message token(MD5 hex digest) or empty string
494             # if the any argument is missing
495             # @see http://en.wikipedia.org/wiki/ASCII
496             # @see https://metacpan.org/pod/Digest::MD5
497 3611   50 3611 0 126754 my $class = shift || return '';
498 3611   100     10412 my $addr1 = shift || return '';
499 3610   100     10199 my $addr2 = shift || return '';
500 3609   100     9674 my $epoch = shift // return '';
501              
502             # Format: STX(0x02) Sender-Address RS(0x1e) Recipient-Address ETX(0x03)
503 3608         85241 return Digest::SHA::sha1_hex(sprintf("\x02%s\x1e%s\x1e%d\x03", lc $addr1, lc $addr2, $epoch));
504             }
505              
506              
507             sub damn {
508             # Convert from object to hash reference
509             # @return [Hash] Data in Hash reference
510 2933     2933 1 9564273 my $self = shift;
511 2933         9153 my $data = undef;
512 2933         8222 state $stringdata = [qw|
513             action alias catch command decodedby deliverystatus destination diagnosticcode diagnostictype
514             feedbackid feedbacktype lhost listid messageid origin reason replycode rhost senderdomain
515             subject timezoneoffset token
516             |];
517 2933         6850 eval {
518 2933         6657 my $v = {};
519 2933   100     24922 $v->{ $_ } = $self->$_ // '' for @$stringdata;
520 2933         402388 $v->{'hardbounce'} = int $self->hardbounce;
521 2933         21934 $v->{'toxic'} = int $self->toxic;
522 2933         18347 $v->{'addresser'} = $self->addresser->address;
523 2933         49475 $v->{'recipient'} = $self->recipient->address;
524 2933         37865 $v->{'timestamp'} = $self->timestamp->epoch;
525 2933         59156 $data = $v;
526             };
527 2933         79795 return $data;
528             }
529              
530             sub dump {
531             # Data dumper
532             # @param [String] type Data format: json, yaml
533             # @return [String] Dumped data or an empty string when the argument is neither "json" nor "yaml"
534 1523     1523 0 23598903 my $self = shift;
535 1523 100 100     10816 my $type = shift || 'json'; return "" unless $type =~ /\A(?:json|yaml)\z/;
  1523         13184  
536              
537 1485         10371 my $referclass = 'Sisimai::Fact::'.uc($type);
538 1485         4924 my $modulepath = 'Sisimai/Fact/'.uc($type).'.pm';
539              
540 1485         65142 require $modulepath;
541 1485         17677 return $referclass->dump($self);
542             }
543              
544             1;
545             __END__