File Coverage

lib/Sisimai/RFC3464.pm
Criterion Covered Total %
statement 130 141 92.2
branch 90 114 78.9
condition 40 62 64.5
subroutine 6 6 100.0
pod 2 2 100.0
total 268 325 82.4


line stmt bran cond sub pod time code
1             package Sisimai::RFC3464;
2 12     12   4882 use feature ':5.10';
  12         26  
  12         867  
3 12     12   65 use strict;
  12         24  
  12         235  
4 12     12   58 use warnings;
  12         22  
  12         265  
5 12     12   61 use Sisimai::Lhost;
  12         41  
  12         39631  
6              
7             # http://tools.ietf.org/html/rfc3464
8 2     2 1 855 sub description { 'Fallback Module for MTAs' };
9             sub make {
10             # Detect an error for RFC3464
11             # @param [Hash] mhead Message headers of a bounce email
12             # @param [String] mbody Message body of a bounce email
13             # @return [Hash] Bounce data list and message/rfc822 part
14             # @return [Undef] failed to parse or the arguments are missing
15 182     182 1 618 my $class = shift;
16 182   100     472 my $mhead = shift // return undef;
17 181   50     458 my $mbody = shift // return undef;
18 181         248 my $match = 0;
19              
20 181 50       503 return undef unless keys %$mhead;
21 181 50       557 return undef unless ref $mbody eq 'SCALAR';
22              
23 181         299 state $indicators = Sisimai::Lhost->INDICATORS;
24 181         407 state $markingsof = {
25             'command' => qr/[ ](RCPT|MAIL|DATA)[ ]+command\b/,
26             'message' => qr{\A(?>
27             content-type:[ ]*(?:
28             message/x?delivery-status
29             |message/disposition-notification
30             |text/plain;[ ]charset=
31             )
32             |the[ ]original[ ]message[ ]was[ ]received[ ]at[ ]
33             |this[ ]report[ ]relates[ ]to[ ]your[ ]message
34             |your[ ]message[ ](?:
35             could[ ]not[ ]be[ ]delivered
36             |was[ ]not[ ]delivered[ ]to[ ]the[ ]following[ ]recipients
37             )
38             )
39             }x,
40             'error' => qr/\A(?:[45]\d\d[ \t]+|[<][^@]+[@][^@]+[>]:?[ \t]+)/,
41             'rfc822' => qr{\A(?>
42             content-type:[ ]*(?:message/rfc822|text/rfc822-headers)
43             |return-path:[ ]*[<].+[>]
44             )\z
45             }x,
46             };
47              
48 181         657 my $dscontents = [Sisimai::Lhost->DELIVERYSTATUS];
49 181         426 my $rfc822text = ''; # (String) message/rfc822 part text
50 181         315 my $maybealias = ''; # (String) Original-Recipient field
51 181         265 my $blanklines = 0; # (Integer) The number of blank lines
52 181         240 my $readcursor = 0; # (Integer) Points the current cursor position
53 181         287 my $recipients = 0; # (Integer) The number of 'Final-Recipient' header
54 181         258 my $itisbounce = 0; # (Integer) Flag for that an email is a bounce
55 181         529 my $connheader = {
56             'date' => '', # The value of Arrival-Date header
57             'rhost' => '', # The value of Reporting-MTA header
58             'lhost' => '', # The value of Received-From-MTA header
59             };
60 181         256 my $v = undef;
61 181         312 my $p = '';
62              
63 181         2867 for my $e ( split("\n", $$mbody) ) {
64             # Read each line between the start of the message and the start of rfc822 part.
65 6603         8831 my $d = lc $e;
66 6603 100       8959 unless( $readcursor ) {
67             # Beginning of the bounce message or message/delivery-status part
68 2272 100       7067 if( $d =~ $markingsof->{'message'} ) {
69 117         272 $readcursor |= $indicators->{'deliverystatus'};
70 117         181 next;
71             }
72             }
73              
74 6486 100       9890 unless( $readcursor & $indicators->{'message-rfc822'} ) {
75             # Beginning of the original message part(message/rfc822)
76 4148 100       11929 if( $d =~ $markingsof->{'rfc822'} ) {
77 124         354 $readcursor |= $indicators->{'message-rfc822'};
78 124         183 next;
79             }
80             }
81              
82 6362 100       9235 if( $readcursor & $indicators->{'message-rfc822'} ) {
83             # message/rfc822 OR text/rfc822-headers part
84 2338 100       3060 unless( length $e ) {
85 195 100       552 last if ++$blanklines > 1;
86 114         158 next;
87             }
88 2143         3991 $rfc822text .= sprintf("%s\n", $e);
89              
90             } else {
91             # message/delivery-status part
92 4024 100       5937 next unless $readcursor & $indicators->{'deliverystatus'};
93 1902 100       2839 next unless length $e;
94              
95 1479         1645 $v = $dscontents->[-1];
96 1479 100       7080 if( $e =~ /\A(Original|Final)-[Rr]ecipient:[ ]*.+;[ ]*([^ ]+)\z/ ) {
    50          
    100          
    100          
    50          
    100          
    100          
97             # 2.3.2 Final-Recipient field
98             # The Final-Recipient field indicates the recipient for which this set
99             # of per-recipient fields applies. This field MUST be present in each
100             # set of per-recipient data.
101             # The syntax of the field is as follows:
102             #
103             # final-recipient-field =
104             # "Final-Recipient" ":" address-type ";" generic-address
105             #
106             # 2.3.1 Original-Recipient field
107             # The Original-Recipient field indicates the original recipient address
108             # as specified by the sender of the message for which the DSN is being
109             # issued.
110             #
111             # original-recipient-field =
112             # "Original-Recipient" ":" address-type ";" generic-address
113             #
114             # generic-address = *text
115 158 100       461 if( $1 eq 'Original' ) {
116             # Original-Recipient: ...
117 37         143 $maybealias = $2;
118              
119             } else {
120             # Final-Recipient: ...
121 121   100     501 my $x = $v->{'recipient'} || '';
122 121         723 my $y = Sisimai::Address->s3s4($2);
123 121 50       579 $y = $maybealias unless Sisimai::RFC5322->is_emailaddress($y);
124              
125 121 100 66     722 if( $x && $x ne $y ) {
126             # There are multiple recipient addresses in the message body.
127 10         37 push @$dscontents, Sisimai::Lhost->DELIVERYSTATUS;
128 10         28 $v = $dscontents->[-1];
129             }
130 121         219 $v->{'recipient'} = $y;
131 121         216 $recipients++;
132 121   100     457 $itisbounce ||= 1;
133              
134 121   66     483 $v->{'alias'} ||= $maybealias;
135 121         250 $maybealias = '';
136             }
137             } elsif( $e =~ /\AX-Actual-Recipient:[ ]*(?:RFC|rfc)822;[ ]*([^ ]+)\z/ ) {
138             # X-Actual-Recipient: RFC822; |IFS=' ' && exec procmail -f- || exit 75 ...
139             # X-Actual-Recipient: rfc822; kijitora@neko.example.jp
140 0 0       0 $v->{'alias'} = $1 unless $1 =~ /[ \t]+/;
141              
142             } elsif( $e =~ /\AAction:[ ]*(.+)\z/ ) {
143             # 2.3.3 Action field
144             # The Action field indicates the action performed by the Reporting-MTA
145             # as a result of its attempt to deliver the message to this recipient
146             # address. This field MUST be present for each recipient named in the
147             # DSN.
148             # The syntax for the action-field is:
149             #
150             # action-field = "Action" ":" action-value
151             # action-value =
152             # "failed" / "delayed" / "delivered" / "relayed" / "expanded"
153             #
154             # The action-value may be spelled in any combination of upper and lower
155             # case characters.
156 121         426 $v->{'action'} = lc $1;
157 121 50       445 $v->{'action'} = $1 if $v->{'action'} =~ /\A([^ ]+)[ ]/; # failed (bad destination mailbox address)
158              
159             } elsif( $e =~ /\AStatus:[ ]*(\d[.]\d+[.]\d+)/ ) {
160             # 2.3.4 Status field
161             # The per-recipient Status field contains a transport-independent
162             # status code that indicates the delivery status of the message to that
163             # recipient. This field MUST be present for each delivery attempt
164             # which is described by a DSN.
165             #
166             # The syntax of the status field is:
167             #
168             # status-field = "Status" ":" status-code
169             # status-code = DIGIT "." 1*3DIGIT "." 1*3DIGIT
170 121         393 $v->{'status'} = $1;
171              
172             } elsif( $e =~ /\AStatus:[ ]*(\d+[ ]+.+)\z/ ) {
173             # Status: 553 Exceeded maximum inbound message size
174 0         0 $v->{'alterrors'} = $1;
175              
176             } elsif( $e =~ /Remote-MTA:[ ]*(?:DNS|dns);[ ]*(.+)\z/ ) {
177             # 2.3.5 Remote-MTA field
178             # The value associated with the Remote-MTA DSN field is a printable
179             # ASCII representation of the name of the "remote" MTA that reported
180             # delivery status to the "reporting" MTA.
181             #
182             # remote-mta-field = "Remote-MTA" ":" mta-name-type ";" mta-name
183             #
184             # NOTE: The Remote-MTA field preserves the "while talking to"
185             # information that was provided in some pre-existing nondelivery
186             # reports.
187             #
188             # This field is optional. It MUST NOT be included if no remote MTA was
189             # involved in the attempted delivery of the message to that recipient.
190 38         138 $v->{'rhost'} = lc $1;
191              
192             } elsif( $e =~ /\ALast-Attempt-Date:[ ]*(.+)\z/ ) {
193             # 2.3.7 Last-Attempt-Date field
194             # The Last-Attempt-Date field gives the date and time of the last
195             # attempt to relay, gateway, or deliver the message (whether successful
196             # or unsuccessful) by the Reporting MTA. This is not necessarily the
197             # same as the value of the Date field from the header of the message
198             # used to transmit this delivery status notification: In cases where
199             # the DSN was generated by a gateway, the Date field in the message
200             # header contains the time the DSN was sent by the gateway and the DSN
201             # Last-Attempt-Date field contains the time the last delivery attempt
202             # occurred.
203             #
204             # last-attempt-date-field = "Last-Attempt-Date" ":" date-time
205 36         141 $v->{'date'} = $1;
206              
207             } else {
208 1005 100 100     3342 if( $e =~ /\ADiagnostic-Code:[ ]*(.+?);[ ]*(.+)\z/ ) {
    100          
    100          
209             # 2.3.6 Diagnostic-Code field
210             # For a "failed" or "delayed" recipient, the Diagnostic-Code DSN field
211             # contains the actual diagnostic code issued by the mail transport.
212             # Since such codes vary from one mail transport to another, the
213             # diagnostic-type sub-field is needed to specify which type of
214             # diagnostic code is represented.
215             #
216             # diagnostic-code-field =
217             # "Diagnostic-Code" ":" diagnostic-type ";" *text
218 79         348 $v->{'spec'} = uc $1;
219 79         209 $v->{'diagnosis'} = $2;
220              
221             } elsif( $e =~ /\ADiagnostic-Code:[ ]*(.+)\z/ ) {
222             # No value of "diagnostic-type"
223             # Diagnostic-Code: 554 ...
224 5         17 $v->{'diagnosis'} = $1;
225              
226             } elsif( index($p, 'Diagnostic-Code:') == 0 && $e =~ /\A[ \t]+(.+)\z/ ) {
227             # Continued line of the value of Diagnostic-Code header
228 26         115 $v->{'diagnosis'} .= ' '.$1;
229 26         82 $e = 'Diagnostic-Code: '.$e;
230              
231             } else {
232 895 100       2966 if( $e =~ /\AReporting-MTA:[ ]*(?:DNS|dns);[ ]*(.+)\z/ ) {
    100          
    100          
233             # 2.2.2 The Reporting-MTA DSN field
234             #
235             # reporting-mta-field =
236             # "Reporting-MTA" ":" mta-name-type ";" mta-name
237             # mta-name = *text
238             #
239             # The Reporting-MTA field is defined as follows:
240             #
241             # A DSN describes the results of attempts to deliver, relay, or gateway
242             # a message to one or more recipients. In all cases, the Reporting-MTA
243             # is the MTA that attempted to perform the delivery, relay, or gateway
244             # operation described in the DSN. This field is required.
245 111   33     757 $connheader->{'rhost'} ||= lc $1;
246              
247             } elsif( $e =~ /\AReceived-From-MTA:[ ]*(?:DNS|dns);[ ]*(.+)\z/ ) {
248             # 2.2.4 The Received-From-MTA DSN field
249             # The optional Received-From-MTA field indicates the name of the MTA
250             # from which the message was received.
251             #
252             # received-from-mta-field =
253             # "Received-From-MTA" ":" mta-name-type ";" mta-name
254             #
255             # If the message was received from an Internet host via SMTP, the
256             # contents of the mta-name sub-field SHOULD be the Internet domain name
257             # supplied in the HELO or EHLO command, and the network address used by
258             # the SMTP client SHOULD be included as a comment enclosed in
259             # parentheses. (In this case, the MTA-name-type will be "dns".)
260 50         201 $connheader->{'lhost'} = lc $1;
261              
262             } elsif( $e =~ /\AArrival-Date:[ ]*(.+)\z/ ) {
263             # 2.2.5 The Arrival-Date DSN field
264             # The optional Arrival-Date field indicates the date and time at which
265             # the message arrived at the Reporting MTA. If the Last-Attempt-Date
266             # field is also provided in a per-recipient field, this can be used to
267             # determine the interval between when the message arrived at the
268             # Reporting MTA and when the report was issued for that recipient.
269             #
270             # arrival-date-field = "Arrival-Date" ":" date-time
271 88         310 $connheader->{'date'} = $1;
272              
273             } else {
274             # Get error message
275 646 100       1377 next if $e =~ /\A[ -]+/;
276 490 100       1845 next unless $e =~ $markingsof->{'error'};
277              
278             # 500 User Unknown
279             # Unknown
280 29         190 $v->{'alterrors'} .= ' '.$e;
281             }
282             }
283             }
284             } # End of message/delivery-status
285             } continue {
286             # Save the current line for the next loop
287 6522         9033 $p = $e;
288             }
289              
290             BODY_PARSER_FOR_FALLBACK: {
291             # Fallback, parse entire message body
292 181 100       775 last if $recipients;
  181         566  
293              
294             # Failed to get a recipient address at code above
295 70 100 50     631 $match ||= 1 if lc($mhead->{'from'}) =~ /\b(?:postmaster|mailer-daemon|root)[@]/;
296 70 100 50     827 $match ||= 1 if lc($mhead->{'subject'}) =~ qr{(?>
297             delivery[ ](?:failed|failure|report)
298             |failure[ ]notice
299             |mail[ ](?:delivery|error)
300             |non[-]delivery
301             |returned[ ]mail
302             |undeliverable[ ]mail
303             |warning:[ ]
304             )
305             }x;
306 70 100       284 if( defined $mhead->{'return-path'} ) {
307             # Check the value of Return-Path of the message
308 50 100 100     397 $match ||= 1 if lc($mhead->{'return-path'}) =~ /(?:[<][>]|mailer-daemon)/;
309             }
310 70 100       185 last unless $match;
311              
312 23         48 state $re_skip = qr{(?>
313             \A[-]+=
314             |\A\s+\z
315             |\A\s*--
316             |\A\s+[=]\d+
317             |\Ahi[ ][!]
318             |content-(?:description|disposition|transfer-encoding|type):[ ]
319             |(?:name|charset)=
320             |--\z
321             |:[ ]--------
322             )
323             }x;
324 23         41 state $re_stop = qr{(?:
325             \A[*][*][*][ ].+[ ].+[ ][*][*][*]
326             |\Acontent-type:[ ]message/delivery-status
327             |\Ahere[ ]is[ ]a[ ]copy[ ]of[ ]the[ ]first[ ]part[ ]of[ ]the[ ]message
328             |\Athe[ ]non-delivered[ ]message[ ]is[ ]attached[ ]to[ ]this[ ]message.
329             |\Areceived:[ \t]*
330             |\Areceived-from-mta:[ \t]*
331             |\Areporting-mta:[ \t]*
332             |\Areturn-path:[ \t]*
333             |\Aa[ ]copy[ ]of[ ]the[ ]original[ ]message[ ]below[ ]this[ ]line:
334             |attachment[ ]is[ ]a[ ]copy[ ]of[ ]the[ ]message
335             |below[ ]is[ ]a[ ]copy[ ]of[ ]the[ ]original[ ]message:
336             |below[ ]this[ ]line[ ]is[ ]a[ ]copy[ ]of[ ]the[ ]message
337             |message[ ]contains[ ].+[ ]file[ ]attachments
338             |message[ ]text[ ]follows:[ ]
339             |original[ ]message[ ]follows
340             |the[ ]attachment[ ]contains[ ]the[ ]original[ ]mail[ ]headers
341             |the[ ]first[ ]\d+[ ]lines[ ]
342             |unsent[ ]message[ ]below
343             |your[ ]message[ ]reads[ ][(]in[ ]part[)]:
344             )
345             }x;
346 23         49 state $re_addr = qr{(?:
347             \A\s*
348             |\A["].+["]\s*
349             |\A[ \t]*recipient:[ \t]*
350             |\A[ ]*address:[ ]
351             |addressed[ ]to[ ]
352             |could[ ]not[ ]be[ ]delivered[ ]to:[ ]
353             |delivered[ ]to[ ]+
354             |delivery[ ]failed:[ ]
355             |did[ ]not[ ]reach[ ]the[ ]following[ ]recipient:[ ]
356             |error-for:[ ]+
357             |failed[ ]recipient:[ ]
358             |failed[ ]to[ ]deliver[ ]to[ ]
359             |intended[ ]recipient:[ ]
360             |mailbox[ ]is[ ]full:[ ]
361             |rcpt[ ]to:
362             |smtp[ ]server[ ][<].+[>][ ]rejected[ ]recipient[ ]
363             |the[ ]following[ ]recipients[ ]returned[ ]permanent[ ]errors:[ ]
364             |the[ ]following[ ]message[ ]to[ ]
365             |unknown[ ]user:[ ]
366             |undeliverable[ ]to[ ]
367             |undeliverable[ ]address:[ ]*
368             |you[ ]sent[ ]mail[ ]to[ ]
369             |your[ ]message[ ]to[ ]
370             )
371             ['"]?[<]?([^\s\n\r@=<>]+[@][-.0-9a-z]+[.][0-9a-z]+)[>]?['"]?
372             }x;
373              
374 23         41 my $b = $dscontents->[-1];
375 23         146 for my $e ( split("\n", $$mbody) ) {
376             # Get the recipient's email address and error messages.
377 152         279 my $d = lc $e;
378 152 100       482 last if $d =~ $markingsof->{'rfc822'};
379 151 100       2022 last if $d =~ $re_stop;
380              
381 146 100       284 next unless length $e;
382 107 100       1858 next if $d =~ $re_skip;
383 89 50       249 next if index($e, '*') == 0;
384              
385 89 100       692 if( $d =~ $re_addr ) {
    50          
386             # May be an email address
387 20   50     115 my $x = $b->{'recipient'} || '';
388 20         134 my $y = Sisimai::Address->s3s4($1);
389 20 50       142 next unless Sisimai::RFC5322->is_emailaddress($y);
390              
391 20 50 33     134 if( $x && $x ne $y ) {
392             # There are multiple recipient addresses in the message body.
393 0         0 push @$dscontents, Sisimai::Lhost->DELIVERYSTATUS;
394 0         0 $b = $dscontents->[-1];
395             }
396 20         65 $b->{'recipient'} = $y;
397 20         37 $recipients++;
398 20   50     86 $itisbounce ||= 1;
399              
400             } elsif( $e =~ /[(](?:expanded|generated)[ ]from:?[ ]([^@]+[@][^@]+)[)]/ ) {
401             # (expanded from: neko@example.jp)
402 0         0 $b->{'alias'} = Sisimai::Address->s3s4($1);
403             }
404 89         261 $b->{'diagnosis'} .= ' '.$e;
405             }
406             } # END OF BODY_PARSER_FOR_FALLBACK
407 181 100       709 return undef unless $itisbounce;
408              
409 131 50 33     481 if( $recipients == 0 && $rfc822text =~ /^To:[ ]*(.+)/m ) {
410             # Try to get a recipient address from "To:" header of the original message
411 0 0       0 if( my $r = Sisimai::Address->find($1, 1) ) {
412             # Found a recipient address
413 0 0       0 push @$dscontents, Sisimai::Lhost->DELIVERYSTATUS if scalar(@$dscontents) == $recipients;
414 0         0 my $b = $dscontents->[-1];
415 0         0 $b->{'recipient'} = $r->[0]->{'address'};
416 0         0 $recipients++;
417             }
418             }
419 131 50       288 return undef unless $recipients;
420              
421 131         2826 require Sisimai::MDA;
422 131         1014 my $mdabounced = Sisimai::MDA->make($mhead, $mbody);
423 131         322 for my $e ( @$dscontents ) {
424             # Set default values if each value is empty.
425 141   100     1655 $e->{ $_ } ||= $connheader->{ $_ } || '' for keys %$connheader;
      100        
426              
427 141 50 66     601 if( exists $e->{'alterrors'} && $e->{'alterrors'} ) {
428             # Copy alternative error message
429 29   66     107 $e->{'diagnosis'} ||= $e->{'alterrors'};
430 29 50 33     200 if( index($e->{'diagnosis'}, '-') == 0 || substr($e->{'diagnosis'}, -2, 2) eq '__') {
431             # Override the value of diagnostic code message
432 0 0       0 $e->{'diagnosis'} = $e->{'alterrors'} if $e->{'alterrors'};
433             }
434 29         63 delete $e->{'alterrors'};
435             }
436 141         767 $e->{'diagnosis'} = Sisimai::String->sweep($e->{'diagnosis'});
437              
438 141 100       672 if( $mdabounced ) {
439             # Make bounce data by the values returned from Sisimai::MDA->make()
440 12   50     53 $e->{'agent'} = $mdabounced->{'mda'} || 'RFC3464';
441 12   50     40 $e->{'reason'} = $mdabounced->{'reason'} || 'undefined';
442 12 50       39 $e->{'diagnosis'} = $mdabounced->{'message'} if $mdabounced->{'message'};
443 12         22 $e->{'command'} = '';
444             }
445 141   66     578 $e->{'date'} ||= $mhead->{'date'};
446 141   50     419 $e->{'status'} ||= Sisimai::SMTP::Status->find($e->{'diagnosis'}) || '';
      66        
447 141 50       893 $e->{'command'} = $1 if $e->{'diagnosis'} =~ $markingsof->{'command'};
448             }
449 131         1080 return { 'ds' => $dscontents, 'rfc822' => $rfc822text };
450             }
451              
452             1;
453             __END__