File Coverage

lib/Sisimai/Lhost/Exim.pm
Criterion Covered Total %
statement 176 181 97.2
branch 112 130 86.1
condition 66 92 71.7
subroutine 6 6 100.0
pod 2 2 100.0
total 362 411 88.0


line stmt bran cond sub pod time code
1             package Sisimai::Lhost::Exim;
2 52     52   4572 use parent 'Sisimai::Lhost';
  52         74  
  52         319  
3 52     52   3473 use v5.26;
  52         136  
4 52     52   189 use strict;
  52         106  
  52         1138  
5 52     52   177 use warnings;
  52         71  
  52         106009  
6              
7 1     1 1 2 sub description { 'Exim' }
8             sub inquire {
9             # Detect an error from Exim Internet Mailer
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 decode or the arguments are missing
14             # @since v4.0.0
15 1409     1409 1 6491 my $class = shift;
16 1409   100     3444 my $mhead = shift // return undef;
17 1406   100     2470 my $mbody = shift // return undef;
18 1403 100       4931 return undef if grep { index($$mbody, $_) > 0 } __PACKAGE__->BannerDTAG->@*;
  5612         10535  
19              
20             # Message-Id:
21             # X-Failed-Recipients: kijitora@example.ed.jp
22 1393   100     3433 my $messageidv = $mhead->{"message-id"} || "";
23 1393         3167 my $emailtitle = [
24             "Delivery Status Notification",
25             "Mail delivery failed",
26             "Mail failure",
27             "Message frozen",
28             "Warning: message ",
29             "error(s) in forwarding or filtering",
30             ];
31 1393 100       1967 my $proceedsto = 0; $proceedsto++ if index($mhead->{"from"}, "Mail Delivery System") > -1;
  1393         3962  
32              
33 1393         2864 while( $messageidv ne "" ) {
34             # Message-Id:
35 1288 100 100     6646 last if index($messageidv, '<') != 0 || index($messageidv, '-') != 8 || index($messageidv, '@') != 18;
      66        
36 223         267 $proceedsto++; last;
  223         242  
37             }
38 1393         2170 for my $e ( @$emailtitle ) {
39             # Subject: Mail delivery failed: returning message to sender
40             # Subject: Mail delivery failed
41             # Subject: Message frozen
42 4516 100       7104 next if index($mhead->{"subject"}, $e) < 0;
43 879         1034 $proceedsto++; last;
  879         1092  
44             }
45              
46             # Exim clones of the third Parties
47             # 1. McAfee Saas (Formerly MXLogic)
48 1393 100 50     1553 my $thirdparty = 0; $thirdparty ||= 1 if exists $mhead->{"x-mx-bounce"};
  1393         2750  
49 1393 50 0     2448 $thirdparty ||= 1 if exists $mhead->{"x-mxl-hash"};
50 1393 100 50     2345 $thirdparty ||= 1 if exists $mhead->{"x-mxl-notehash"};
51 1393 100 100     2703 $thirdparty ||= 1 if index($messageidv, " -1;
52 1393 100 66     5774 return undef if $proceedsto < 2 && $thirdparty == 0;
53              
54 301         867 require Sisimai::Reason;
55 301         680 require Sisimai::Address;
56 301         647 require Sisimai::SMTP::Command;
57 301         651 require Sisimai::SMTP::Failure;
58 301         411 state $indicators = __PACKAGE__->INDICATORS;
59 301         352 state $boundaries = [
60             # deliver.c:6423| if (bounce_return_body) fprintf(f,
61             # deliver.c:6424|"------ This is a copy of the message, including all the headers. ------\n");
62             # deliver.c:6425| else fprintf(f,
63             # deliver.c:6426|"------ This is a copy of the message's headers. ------\n");
64             '------ This is a copy of the message, including all the headers. ------',
65             'Content-Type: message/rfc822',
66             "Included is a copy of the message header:\n-----------------------------------------", # MXLogic
67             ];
68 301         378 state $startingof = {
69             # Error text strings which defined in exim/src/deliver.c
70             #
71             # deliver.c:6292| fprintf(f,
72             # deliver.c:6293|"This message was created automatically by mail delivery software.\n");
73             # deliver.c:6294| if (to_sender)
74             # deliver.c:6295| {
75             # deliver.c:6296| fprintf(f,
76             # deliver.c:6297|"\nA message that you sent could not be delivered to one or more of its\n"
77             # deliver.c:6298|"recipients. This is a permanent error. The following address(es) failed:\n");
78             # deliver.c:6299| }
79             # deliver.c:6300| else
80             # deliver.c:6301| {
81             # deliver.c:6302| fprintf(f,
82             # deliver.c:6303|"\nA message sent by\n\n <%s>\n\n"
83             # deliver.c:6304|"could not be delivered to one or more of its recipients. The following\n"
84             # deliver.c:6305|"address(es) failed:\n", sender_address);
85             # deliver.c:6306| }
86             "alias" => [" an undisclosed address"],
87             "command" => ["SMTP error from remote ", "LMTP error after "],
88             'deliverystatus' => ["Content-Type: message/delivery-status"],
89             'frozen' => [" has been frozen", " was frozen on arrival"],
90             'message' => [
91             "This message was created automatically by mail delivery software.",
92             "A message that you sent was rejected by the local scannning code",
93             "A message that you sent contained one or more recipient addresses ",
94             "A message that you sent could not be delivered to all of its recipients",
95             " has been frozen",
96             " was frozen on arrival",
97             " router encountered the following error(s):",
98             ],
99             };
100 301         346 state $messagesof = {
101             # find exim/ -type f -exec grep 'message = US' {} /dev/null \;
102             # route.c:1158| DEBUG(D_uid) debug_printf("getpwnam() returned NULL (user not found)\n");
103             "userunknown" => ["user not found"],
104             # parser.c:666| *errorptr = string_sprintf("%s (expected word or \"<\")", *errorptr);
105             # parser.c:701| if(bracket_count++ > 5) FAILED(US"angle-brackets nested too deep");
106             # parser.c:738| FAILED(US"domain missing in source-routed address");
107             # parser.c:747| : string_sprintf("malformed address: %.32s may not follow %.*s",
108             "syntaxerror" => [
109             "angle-brackets nested too deep",
110             'expected word or "<"',
111             "domain missing in source-routed address",
112             "malformed address:",
113             ],
114             };
115 301         335 state $delayedfor = [
116             # deliver.c:7475| "No action is required on your part. Delivery attempts will continue for\n"
117             # smtp.c:3508| US"retry time not reached for any host after a long failure period" :
118             # deliver.c:7459| print_address_error(addr, f, US"Delay reason: ");
119             "No action is required on your part",
120             "retry time not reached for any host after a long failure period",
121             "Delay reason: ",
122             ];
123              
124 301 100       1046 if( index($$mbody, "\n----- This is a copy ") > -1 ) {
125             # There are extremely rare cases where there are only five hyphens.
126             # https://github.com/sisimai/set-of-emails/blob/master/maildir/bsd/lhost-exim-05.eml
127             # ----- This is a copy of the message, including all the headers. ------
128 5         9 my $p0 = index($$mbody, "\n----- This is a copy ");
129 5         18 substr($$mbody, $p0 + 1, 1, "--");
130             }
131              
132 301         1208 my $fieldtable = Sisimai::RFC1894->FIELDTABLE;
133 301         864 my $dscontents = [__PACKAGE__->DELIVERYSTATUS]; my $v = undef;
  301         539  
134 301         1085 my $emailparts = Sisimai::RFC5322->part($mbody, $boundaries);
135 301         460 my $readcursor = 0; # Points the current cursor position
136 301         365 my $nextcursor = 0;
137 301         387 my $recipients = 0; # The number of 'Final-Recipient' header
138 301         443 my $boundary00 = ''; # Boundary string
139              
140             # Get the boundary string and set regular expression for matching with the boundary string.
141 301 100       901 $boundary00 = Sisimai::RFC2045->boundary($mhead->{'content-type'}) if $mhead->{'content-type'};
142              
143 301         326 my $p1 = -1; my $p2 = -1;
  301         293  
144 301         1694 for my $e ( split("\n", $emailparts->[0]) ) {
145             # Read error messages and delivery status lines from the head of the email to the previous
146             # line of the beginning of the original message.
147 4434 100       5087 unless( $readcursor ) {
148             # Beginning of the bounce message or message/delivery-status part
149 1039 100       1331 if( grep { index($e, $_) > -1 } $startingof->{'message'}->@* ) {
  7273         7990  
150             # Check the message defined in $startingof->{"message"}, {"frozen"}
151 296         580 $readcursor |= $indicators->{'deliverystatus'};
152 296 50       489 next unless grep { index($e, $_) > -1 } $startingof->{'frozen'}->@*;
  592         982  
153             }
154             }
155 4138 100 100     7927 next if ($readcursor & $indicators->{'deliverystatus'}) == 0 || $e eq "";
156              
157             # This message was created automatically by mail delivery software.
158             #
159             # A message that you sent could not be delivered to one or more of its
160             # recipients. This is a permanent error. The following address(es) failed:
161             #
162             # kijitora@example.jp
163             # SMTP error from remote mail server after RCPT TO::
164             # host neko.example.jp [192.0.2.222]: 550 5.1.1 ... User Unknown
165 2584         2344 $v = $dscontents->[-1];
166              
167 2584         2240 my $cv = "";
168 2584         2166 my $ce = 0;
169 2584         1908 while(1) {
170             # Check if the line matche the following patterns:
171 2584 100       3370 last if index($e, ' ') != 0; # The line should start with " " (2 spaces)
172 1037 100       1445 last if index($e, '@' ) < 2; # "@" should be included (email)
173 554 50       717 last if index($e, '.' ) < 2; # "." should be included (domain part)
174 554 100       867 last if index($e, 'pipe to |') > -1; # Exclude "pipe to /path/to/prog" line
175              
176 544         651 my $cx = substr($e, 2, 1);
177 544 100       846 last if $cx eq " "; # The 3rd character is " "
178 274 100 100     1051 last if $thirdparty == 0 && $cx eq "<"; # MXLogic returns " :..."
179              
180 264         266 $ce = 1; last;
  264         353  
181             }
182              
183 2584 100 66     3983 if( $ce == 1 || grep { index($e, $_) > 0 } $startingof->{"alias"}->@* ) {
  2320 100 100     7772  
184             # The line is including an email address
185 264 100       488 if( $v->{'recipient'} ) {
186             # There are multiple recipient addresses in the message body.
187 10         34 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
188 10         19 $v = $dscontents->[-1];
189             }
190              
191 264 50       380 if( grep { index($e, $_) > 0 } $startingof->{"alias"}->@* ) {
  264         539  
192             # The line does not include an email address
193             # deliver.c:4549| printed = US"an undisclosed address";
194             # an undisclosed address
195             # (generated from kijitora@example.jp)
196 0         0 $cv = substr($e, 2,);
197              
198             } else {
199             # kijitora@example.jp
200             # sabineko@example.jp: forced freeze
201             # mikeneko@example.jp : ...
202 264         327 $p1 = index($e, "<");
203 264         321 $p2 = index($e, ">:");
204              
205 264 100 66     578 if( $p1 > 1 && $p2 > 1 ) {
206             # There are an email address and an error message in the line
207             # parser.c:743| while (bracket_count-- > 0) if (*s++ != '>')
208             # parser.c:744| {
209             # parser.c:745| *errorptr = s[-1] == 0
210             # parser.c:746| ? US"'>' missing at end of address"
211             # parser.c:747| : string_sprintf("malformed address: %.32s may not follow %.*s",
212             # parser.c:748| s-1, (int)(s - US mailbox - 1), mailbox);
213             # parser.c:749| goto PARSE_FAILED;
214             # parser.c:750| }
215 21         145 $cv = Sisimai::Address->s3s4(substr($e, $p1, $p2 - $p1 - 1));
216 21         73 $v->{'diagnosis'} = substr($e, $p2 + 1,);
217              
218             } else {
219             # There is an email address only in the line
220             # kijitora@example.jp
221 243         1425 $cv = Sisimai::Address->s3s4(substr($e, 2,));
222             }
223 264 50       889 next unless Sisimai::Address->is_emailaddress($cv);
224             }
225 264         668 $v->{'recipient'} = $cv;
226 264         486 $recipients++;
227              
228             } elsif( index($e, " (generated from ") > 0 || index($e, " generated by ") > 0 ) {
229             # (generated from kijitora@example.jp)
230             # pipe to |/bin/echo "Some pipe output"
231             # generated by userx@myhost.test.ex
232 36         106 for my $f ( split(" ", $e) ) {
233             # Find the alias address
234 118 100       201 next if index($f, '@') < 0;
235 31         134 $v->{'alias'} = Sisimai::Address->s3s4($f);
236             }
237             } else {
238 2284 50       2360 if( grep { index($e, $_) > -1 } $startingof->{'frozen'}->@* ) {
  4568 100       6045  
239             # Message *** has been frozen by the system filter.
240             # Message *** was frozen on arrival by ACL.
241 0         0 $v->{'alterrors'} .= $e.' ';
242              
243             } elsif( $boundary00 ) {
244             # --NNNNNNNNNN-eximdsn-MMMMMMMMMM
245             # Content-type: message/delivery-status
246             # ...
247 355 100       654 if( Sisimai::RFC1894->match($e) ) {
248             # $e matched with any field defined in RFC3464
249 160 50       276 next unless my $o = Sisimai::RFC1894->field($e);
250              
251 160 100       356 if( $o->[3] eq 'addr' ) {
    100          
252             # Final-Recipient: rfc822;|/bin/echo "Some pipe output"
253 30 50       77 next unless $o->[0] eq 'final-recipient';
254 30 100 33     155 $v->{'spec'} ||= rindex($o->[2], '@') > -1 ? 'SMTP' : 'X-UNIX';
255              
256             } elsif( $o->[3] eq 'code' ) {
257             # Diagnostic-Code: SMTP; 550 5.1.1 ... User Unknown
258 20         47 $v->{'spec'} = uc $o->[1];
259 20         50 $v->{'diagnosis'} = $o->[2];
260              
261             } else {
262             # Other DSN fields defined in RFC3464
263 110 50       198 next unless exists $fieldtable->{ $o->[0] };
264 110         301 $v->{ $fieldtable->{ $o->[0] } } = $o->[2];
265             }
266             } else {
267             # Error message ?
268 195 100       274 next if $nextcursor;
269              
270             # Content-type: message/delivery-status
271 190 100       376 $nextcursor = 1 if index($e, $startingof->{'deliverystatus'}->[0]) == 0;
272 190 100       461 $v->{'alterrors'} .= $e.' ' if index($e, ' ') == 0;
273             }
274             } else {
275             # There is no boundary string in $boundary00
276 1929 100       2227 if( scalar @$dscontents == $recipients ) {
277             # Error message
278 711         1474 $v->{'diagnosis'} .= $e.' ';
279              
280             } else {
281             # Error message when email address above does not include '@' and domain part.
282             # pipe to |/path/to/prog ...
283             # generated by kijitora@example.com
284 1218 100       1961 next unless index($e, " ") == 0;
285 46         131 $v->{"diagnosis"} .= $e." ";
286             }
287             }
288             }
289             }
290              
291 301 100       897 if( $recipients ) {
292             # Check "an undisclosed address", "unroutable address"
293 254         367 for my $q ( @$dscontents ) {
294             # Replace the recipient address with the value of "alias"
295 264 100       697 next unless $q->{'alias'};
296 6 50 33     40 if( ! $q->{'recipient'} || rindex($q->{'recipient'}, '@') == -1 ) {
297             # The value of "recipient" is empty or does not include "@"
298 0         0 $q->{'recipient'} = $q->{'alias'};
299             }
300             }
301             } else {
302             # Fallback for getting recipient addresses
303 47 100       131 if( defined $mhead->{'x-failed-recipients'} ) {
304             # X-Failed-Recipients: kijitora@example.jp
305 25         61 my @rcptinhead = split(',', $mhead->{'x-failed-recipients'});
306 25         48 for my $e ( @rcptinhead ) { s/\A[ ]+//, s/[ ]+\z// for $e }
  25         93  
307 25         35 $recipients = scalar @rcptinhead;
308              
309 25         55 for my $e ( @rcptinhead ) {
310             # Insert each recipient address into @$dscontents
311 25         67 $dscontents->[-1]->{'recipient'} = $e;
312 25 50       67 next if scalar @$dscontents == $recipients;
313 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
314             }
315             }
316             }
317 301 100       682 return undef unless $recipients;
318              
319             # Get the name of the local MTA
320             # Received: from marutamachi.example.org (c192128.example.net [192.0.2.128])
321 279   50     623 my $receivedby = $mhead->{'received'} || [];
322 279         1090 my $recvdtoken = Sisimai::RFC5322->received($receivedby->[-1]);
323              
324 279         445 for my $e ( @$dscontents ) {
325             # Check the error message, the rhost, the lhost, and the smtp command.
326 289   100     1128 $e->{"alterrors"} ||= "";
327 289 100 66     661 if( ! $e->{'diagnosis'} && length($boundary00) > 0 ) {
328             # Empty Diagnostic-Code: or error message
329             # --NNNNNNNNNN-eximdsn-MMMMMMMMMM
330             # Content-type: message/delivery-status
331             #
332             # Reporting-MTA: dns; the.local.host.name
333             #
334             # Action: failed
335             # Final-Recipient: rfc822;/a/b/c
336             # Status: 5.0.0
337             #
338             # Action: failed
339             # Final-Recipient: rfc822;|/p/q/r
340             # Status: 5.0.0
341 10   50     48 $e->{'diagnosis'} = $dscontents->[0]->{'diagnosis'} || '';
342 10   33     32 $e->{'spec'} ||= $dscontents->[0]->{'spec'};
343 10 50       39 $e->{'alterrors'} = $dscontents->[0]->{'alterrors'} if $dscontents->[0]->{'alterrors'};
344             }
345              
346 289 100       643 if( $e->{'alterrors'} ) {
347             # Copy alternative error message
348 30   66     101 $e->{'diagnosis'} ||= $e->{'alterrors'};
349              
350 30 50 33     231 if( index($e->{'diagnosis'}, '-') == 0 || substr($e->{'diagnosis'}, -2, 2) eq '__' ) {
    100          
351             # Override the value of diagnostic code message
352 0         0 $e->{'diagnosis'} = $e->{'alterrors'};
353              
354             } elsif( length($e->{'diagnosis'}) < length($e->{'alterrors'}) ) {
355             # Override the value of diagnostic code message with the value of alterrors because
356             # the latter includes the former.
357 20         52 $e->{'alterrors'} =~ y/ //s;
358 20 50       110 $e->{'diagnosis'} = $e->{'alterrors'} if index(lc $e->{'alterrors'}, lc $e->{'diagnosis'}) > -1;
359             }
360 30         41 delete $e->{'alterrors'};
361             }
362 289         437 $p1 = index($e->{'diagnosis'}, '__');
363 289 50       533 $e->{'diagnosis'} = substr($e->{'diagnosis'}, 0, $p1) if $p1 > 1;
364              
365 289 100       647 unless( $e->{'rhost'} ) {
366             # Get the remote host name
367             # host neko.example.jp [192.0.2.222]: 550 5.1.1 ... User Unknown
368 269         441 $p1 = index($e->{'diagnosis'}, 'host ');
369 269         367 $p2 = index($e->{'diagnosis'}, ' ', $p1 + 5);
370 269 100       673 $e->{'rhost'} = substr($e->{'diagnosis'}, $p1 + 5, $p2 - $p1 - 5) if $p1 > -1;
371 269   66     619 $e->{'rhost'} ||= $recvdtoken->[1];
372             }
373 289   100     964 $e->{'lhost'} ||= $recvdtoken->[0];
374              
375 289 50       557 unless( $e->{'command'} ) {
376             # Get the SMTP command name for the session
377 289         559 SMTP: for my $r ( $startingof->{"command"}->@* ) {
378             # Verify each regular expression of SMTP commands
379 395 100       905 next if index($e->{'diagnosis'}, $r) < 0;
380 213   100     1501 $e->{'command'} = Sisimai::SMTP::Command->find($e->{'diagnosis'}) || next;
381 183         278 last;
382             }
383              
384             # Detect the reason of bounce
385 289 100 66     1141 if( $e->{'command'} eq 'HELO' || $e->{'command'} eq 'EHLO' ) {
    100          
386             # HELO | Connected to 192.0.2.135 but my name was rejected.
387 5         11 $e->{'reason'} = 'blocked';
388              
389             } elsif( $e->{'command'} eq 'MAIL' ) {
390             # MAIL | Connected to 192.0.2.135 but sender was rejected.
391 76         137 $e->{'reason'} = 'onhold';
392              
393             } else {
394             # Try to match the error message with each message pattern
395 208         469 SESSION: for my $r ( keys %$messagesof ) {
396             # Check each message pattern
397 411 100       640 next unless grep { index($e->{'diagnosis'}, $_) > -1 } $messagesof->{ $r }->@*;
  1035         1860  
398 5         10 $e->{'reason'} = $r;
399 5         9 last;
400             }
401 208 50 0     345 $e->{'reason'} ||= 'expired' if grep { index($e->{'diagnosis'}, $_) > -1 } @$delayedfor;
  624         1198  
402             }
403             }
404              
405             # Prefer the value of smtp reply code in Diagnostic-Code: field
406             # See set-of-emails/maildir/bsd/exim-20.eml
407             #
408             # Action: failed
409             # Final-Recipient: rfc822;userx@test.ex
410             # Status: 5.0.0
411             # Remote-MTA: dns; 127.0.0.1
412             # Diagnostic-Code: smtp; 450 TEMPERROR: retry timeout exceeded
413             #
414             # The value of "Status:" indicates permanent error but the value of SMTP reply code in
415             # Diagnostic-Code: field is "TEMPERROR"!!!!
416 289   100     1636 my $cr = Sisimai::SMTP::Reply->find($e->{'diagnosis'}, $e->{'status'}) || '';
417 289   100     1542 my $cs = Sisimai::SMTP::Status->find($e->{'diagnosis'}, $cr) || '';
418 289   100     976 my $re = $e->{'reason'} || '';
419 289         379 my $cv = "";
420              
421 289 100 66     1500 if( Sisimai::SMTP::Failure->is_temporary($cr) || $re eq 'expired' ) {
422             # Set the pseudo status code as a temporary error
423 10 50       45 $cv = Sisimai::SMTP::Status->code($re, 1) if Sisimai::Reason->is_explicit($re);
424             }
425 289   66     1013 $e->{'replycode'} ||= $cr;
426 289   100     994 $e->{'status'} ||= Sisimai::SMTP::Status->prefer($cv, $cs, $cr);
427 289   100     825 $e->{'command'} ||= '';
428             }
429 279         2058 return {"ds" => $dscontents, "rfc822" => $emailparts->[1]};
430             }
431              
432             1;
433             __END__