1package Sisimai::Lhost::Postfix; 2use parent 'Sisimai::Lhost'; 3use feature ':5.10'; 4use strict; 5use warnings; 6 7sub description { 'Postfix' } 8sub make { 9 # Parse bounce messages from Postfix 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.0.0 15 my $class = shift; 16 my $mhead = shift // return undef; 17 my $mbody = shift // return undef; 18 19 return undef unless $mhead->{'subject'} eq 'Undelivered Mail Returned to Sender'; 20 return undef if $mhead->{'x-aol-ip'}; 21 22 state $indicators = __PACKAGE__->INDICATORS; 23 state $rebackbone = qr<^Content-Type:[ ](?:message/rfc822|text/rfc822-headers)>m; 24 state $markingsof = { 25 # Postfix manual - bounce(5) - http://www.postfix.org/bounce.5.html 26 'message' => qr{\A(?> 27 [ ]+The[ ](?: 28 Postfix[ ](?: 29 program\z # The Postfix program 30 |on[ ].+[ ]program\z # The Postfix on <os name> program 31 ) 32 |\w+[ ]Postfix[ ]program\z # The <name> Postfix program 33 |mail[ \t]system\z # The mail system 34 |\w+[ \t]program\z # The <custmized-name> program 35 ) 36 |This[ ]is[ ]the[ ](?: 37 Postfix[ ]program # This is the Postfix program 38 |\w+[ ]Postfix[ ]program # This is the <name> Postfix program 39 |\w+[ ]program # This is the <customized-name> Postfix program 40 |mail[ ]system[ ]at[ ]host # This is the mail system at host <hostname>. 41 ) 42 ) 43 }x, 44 # 'from'=> qr/ [(]Mail Delivery System[)]\z/, 45 }; 46 47 require Sisimai::RFC1894; 48 require Sisimai::Address; 49 my $fieldtable = Sisimai::RFC1894->FIELDTABLE; 50 my $permessage = {}; # (Hash) Store values of each Per-Message field 51 52 my $dscontents = [__PACKAGE__->DELIVERYSTATUS]; 53 my $emailsteak = Sisimai::RFC5322->fillet($mbody, $rebackbone); 54 my $readcursor = 0; # (Integer) Points the current cursor position 55 my $recipients = 0; # (Integer) The number of 'Final-Recipient' header 56 my $anotherset = {}; # (Hash) Another error information 57 my $nomessages = 0; # (Integer) Delivery report unavailable 58 my @commandset; # (Array) ``in reply to * command'' list 59 my $v = undef; 60 my $p = ''; 61 62 for my $e ( split("\n", $emailsteak->[0]) ) { 63 # Read error messages and delivery status lines from the head of the email 64 # to the previous line of the beginning of the original message. 65 unless( $readcursor ) { 66 # Beginning of the bounce message or message/delivery-status part 67 $readcursor |= $indicators->{'deliverystatus'} if $e =~ $markingsof->{'message'}; 68 next; 69 } 70 next unless $readcursor & $indicators->{'deliverystatus'}; 71 next unless length $e; 72 73 if( my $f = Sisimai::RFC1894->match($e) ) { 74 # $e matched with any field defined in RFC3464 75 next unless my $o = Sisimai::RFC1894->field($e); 76 $v = $dscontents->[-1]; 77 78 if( $o->[-1] eq 'addr' ) { 79 # Final-Recipient: rfc822; kijitora@example.jp 80 # X-Actual-Recipient: rfc822; kijitora@example.co.jp 81 if( $o->[0] eq 'final-recipient' ) { 82 # Final-Recipient: rfc822; kijitora@example.jp 83 if( $v->{'recipient'} ) { 84 # There are multiple recipient addresses in the message body. 85 push @$dscontents, __PACKAGE__->DELIVERYSTATUS; 86 $v = $dscontents->[-1]; 87 } 88 $v->{'recipient'} = $o->[2]; 89 $recipients++; 90 91 } else { 92 # X-Actual-Recipient: rfc822; kijitora@example.co.jp 93 $v->{'alias'} = $o->[2]; 94 } 95 } elsif( $o->[-1] eq 'code' ) { 96 # Diagnostic-Code: SMTP; 550 5.1.1 <userunknown@example.jp>... User Unknown 97 $v->{'spec'} = $o->[1]; 98 $v->{'spec'} = 'SMTP' if $v->{'spec'} eq 'X-POSTFIX'; 99 $v->{'diagnosis'} = $o->[2]; 100 101 } else { 102 # Other DSN fields defined in RFC3464 103 next unless exists $fieldtable->{ $o->[0] }; 104 $v->{ $fieldtable->{ $o->[0] } } = $o->[2]; 105 106 next unless $f == 1; 107 $permessage->{ $fieldtable->{ $o->[0] } } = $o->[2]; 108 } 109 } else { 110 # If you do so, please include this problem report. You can 111 # delete your own text from the attached returned message. 112 # 113 # The mail system 114 # 115 # <userunknown@example.co.jp>: host mx.example.co.jp[192.0.2.153] said: 550 116 # 5.1.1 <userunknown@example.co.jp>... User Unknown (in reply to RCPT TO command) 117 if( index($p, 'Diagnostic-Code:') == 0 && $e =~ /\A[ \t]+(.+)\z/ ) { 118 # Continued line of the value of Diagnostic-Code header 119 $v->{'diagnosis'} .= ' '.$1; 120 $e = 'Diagnostic-Code: '.$e; 121 122 } elsif( $e =~ /\A(X-Postfix-Sender):[ ]*rfc822;[ ]*(.+)\z/ ) { 123 # X-Postfix-Sender: rfc822; shironeko@example.org 124 $emailsteak->[1] .= sprintf("%s: %s\n", $1, $2); 125 126 } else { 127 # Alternative error message and recipient 128 if( $e =~ /[ \t][(]in reply to (?:end of )?([A-Z]{4}).*/ || 129 $e =~ /([A-Z]{4})[ \t]*.*command[)]\z/ ) { 130 # 5.1.1 <userunknown@example.co.jp>... User Unknown (in reply to RCPT TO 131 push @commandset, $1; 132 $anotherset->{'diagnosis'} .= ' '.$e if $anotherset->{'diagnosis'}; 133 134 } elsif( $e =~ /\A[<]([^ ]+[@][^ ]+)[>] [(]expanded from [<](.+)[>][)]:[ \t]*(.+)\z/ ) { 135 # <r@example.ne.jp> (expanded from <kijitora@example.org>): user ... 136 $anotherset->{'recipient'} = $1; 137 $anotherset->{'alias'} = $2; 138 $anotherset->{'diagnosis'} = $3; 139 140 } elsif( $e =~ /\A[<]([^ ]+[@][^ ]+)[>]:(.*)\z/ ) { 141 # <kijitora@exmaple.jp>: ... 142 $anotherset->{'recipient'} = $1; 143 $anotherset->{'diagnosis'} = $2; 144 145 } elsif( index($e, '--- Delivery report unavailable ---') > -1 ) { 146 # postfix-3.1.4/src/bounce/bounce_notify_util.c 147 # bounce_notify_util.c:602|if (bounce_info->log_handle == 0 148 # bounce_notify_util.c:602||| bounce_log_rewind(bounce_info->log_handle)) { 149 # bounce_notify_util.c:602|if (IS_FAILURE_TEMPLATE(bounce_info->template)) { 150 # bounce_notify_util.c:602| post_mail_fputs(bounce, ""); 151 # bounce_notify_util.c:602| post_mail_fputs(bounce, "\t--- delivery report unavailable ---"); 152 # bounce_notify_util.c:602| count = 1; /* xxx don't abort */ 153 # bounce_notify_util.c:602|} 154 # bounce_notify_util.c:602|} else { 155 $nomessages = 1; 156 157 } else { 158 # Get error message continued from the previous line 159 next unless $anotherset->{'diagnosis'}; 160 $anotherset->{'diagnosis'} .= ' '.$e if $e =~ /\A[ \t]{4}(.+)\z/; 161 } 162 } 163 } # End of message/delivery-status 164 } continue { 165 # Save the current line for the next loop 166 $p = $e; 167 } 168 169 unless( $recipients ) { 170 # Fallback: get a recipient address from error messages 171 if( defined $anotherset->{'recipient'} && $anotherset->{'recipient'} ) { 172 # Set a recipient address 173 $dscontents->[-1]->{'recipient'} = $anotherset->{'recipient'}; 174 $recipients++; 175 176 } else { 177 # Get a recipient address from message/rfc822 part if the delivery 178 # report was unavailable: '--- Delivery report unavailable ---' 179 if( $nomessages && $emailsteak->[1] =~ /^To:[ ]*(.+)/m ) { 180 # Try to get a recipient address from To: field in the original 181 # message at message/rfc822 part 182 $dscontents->[-1]->{'recipient'} = Sisimai::Address->s3s4($1); 183 $recipients++; 184 } 185 } 186 } 187 return undef unless $recipients; 188 189 for my $e ( @$dscontents ) { 190 # Set default values if each value is empty. 191 $e->{'lhost'} ||= $permessage->{'rhost'}; 192 $e->{ $_ } ||= $permessage->{ $_ } || '' for keys %$permessage; 193 194 if( exists $anotherset->{'diagnosis'} && $anotherset->{'diagnosis'} ) { 195 # Copy alternative error message 196 $e->{'diagnosis'} ||= $anotherset->{'diagnosis'}; 197 if( $e->{'diagnosis'} =~ /\A\d+\z/ ) { 198 # Override the value of diagnostic code message 199 $e->{'diagnosis'} = $anotherset->{'diagnosis'}; 200 201 } else { 202 # More detailed error message is in "$anotherset" 203 my $as = undef; # status 204 my $ar = undef; # replycode 205 206 if( $e->{'status'} eq '' || substr($e->{'status'}, -4, 4) eq '.0.0' ) { 207 # Check the value of D.S.N. in $anotherset 208 $as = Sisimai::SMTP::Status->find($anotherset->{'diagnosis'}) || ''; 209 if( length($as) > 0 && substr($as, -4, 4) ne '.0.0' ) { 210 # The D.S.N. is neither an empty nor *.0.0 211 $e->{'status'} = $as; 212 } 213 } 214 215 if( $e->{'replycode'} eq '' || substr($e->{'replycode'}, -2, 2) eq '00' ) { 216 # Check the value of SMTP reply code in $anotherset 217 $ar = Sisimai::SMTP::Reply->find($anotherset->{'diagnosis'}) || ''; 218 if( length($ar) > 0 && substr($ar, -2, 2) ne '00' ) { 219 # The SMTP reply code is neither an empty nor *00 220 $e->{'replycode'} = $ar; 221 } 222 } 223 224 if( $as || $ar && ( length($anotherset->{'diagnosis'}) > length($e->{'diagnosis'}) ) ) { 225 # Update the error message in $e->{'diagnosis'} 226 $e->{'diagnosis'} = $anotherset->{'diagnosis'}; 227 } 228 } 229 } 230 $e->{'diagnosis'} = Sisimai::String->sweep($e->{'diagnosis'}); 231 $e->{'command'} = shift @commandset || ''; 232 $e->{'command'} ||= 'HELO' if $e->{'diagnosis'} =~ /refused to talk to me:/; 233 $e->{'spec'} ||= 'SMTP' if $e->{'diagnosis'} =~ /host .+ said:/; 234 } 235 return { 'ds' => $dscontents, 'rfc822' => $emailsteak->[1] }; 236} 237 2381; 239__END__ 240 241=encoding utf-8 242 243=head1 NAME 244 245Sisimai::Lhost::Postfix - bounce mail parser class for C<Postfix>. 246 247=head1 SYNOPSIS 248 249 use Sisimai::Lhost::Postfix; 250 251=head1 DESCRIPTION 252 253Sisimai::Lhost::Postfix parses a bounce email which created by C<Postfix>. 254Methods in the module are called from only Sisimai::Message. 255 256=head1 CLASS METHODS 257 258=head2 C<B<description()>> 259 260C<description()> returns description string of this module. 261 262 print Sisimai::Lhost::Postfix->description; 263 264=head2 C<B<make(I<header data>, I<reference to body string>)>> 265 266C<make()> method parses a bounced email and return results as a array reference. 267See Sisimai::Message for more details. 268 269=head1 AUTHOR 270 271azumakuniyuki 272 273=head1 COPYRIGHT 274 275Copyright (C) 2014-2020 azumakuniyuki, All rights reserved. 276 277=head1 LICENSE 278 279This software is distributed under The BSD 2-Clause License. 280 281=cut 282 283