1package Sisimai::Lhost::qmail; 2use parent 'Sisimai::Lhost'; 3use feature ':5.10'; 4use strict; 5use warnings; 6 7sub description { 'qmail' } 8sub make { 9 # Detect an error from qmail 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 my $match = 0; 19 my $tryto = qr/\A[(]qmail[ ]+\d+[ ]+invoked[ ]+(?:for[ ]+bounce|from[ ]+network)[)]/; 20 21 # Pre process email headers and the body part of the message which generated 22 # by qmail, see https://cr.yp.to/qmail.html 23 # e.g.) Received: (qmail 12345 invoked for bounce); 29 Apr 2009 12:34:56 -0000 24 # Subject: failure notice 25 $match ||= 1 if $mhead->{'subject'} eq 'failure notice'; 26 $match ||= 1 if grep { $_ =~ $tryto } @{ $mhead->{'received'} }; 27 return undef unless $match; 28 29 state $indicators = __PACKAGE__->INDICATORS; 30 state $rebackbone = qr|^--- Below this line is a copy of the message[.]|m; 31 state $startingof = { 32 # qmail-remote.c:248| if (code >= 500) { 33 # qmail-remote.c:249| out("h"); outhost(); out(" does not like recipient.\n"); 34 # qmail-remote.c:265| if (code >= 500) quit("D"," failed on DATA command"); 35 # qmail-remote.c:271| if (code >= 500) quit("D"," failed after I sent the message"); 36 # 37 # Characters: K,Z,D in qmail-qmqpc.c, qmail-send.c, qmail-rspawn.c 38 # K = success, Z = temporary error, D = permanent error 39 'message' => ['Hi. This is the qmail'], 40 'error' => ['Remote host said:'], 41 }; 42 43 state $resmtp = { 44 # Error text regular expressions which defined in qmail-remote.c 45 # qmail-remote.c:225| if (smtpcode() != 220) quit("ZConnected to "," but greeting failed"); 46 'conn' => qr/(?:Error:)?Connected to [^ ]+ but greeting failed[.]/, 47 # qmail-remote.c:231| if (smtpcode() != 250) quit("ZConnected to "," but my name was rejected"); 48 'ehlo' => qr/(?:Error:)?Connected to [^ ]+ but my name was rejected[.]/, 49 # qmail-remote.c:238| if (code >= 500) quit("DConnected to "," but sender was rejected"); 50 # reason = rejected 51 'mail' => qr/(?:Error:)?Connected to [^ ]+ but sender was rejected[.]/, 52 # qmail-remote.c:249| out("h"); outhost(); out(" does not like recipient.\n"); 53 # qmail-remote.c:253| out("s"); outhost(); out(" does not like recipient.\n"); 54 # reason = userunknown 55 'rcpt' => qr/(?:Error:)?[^ ]+ does not like recipient[.]/, 56 # qmail-remote.c:265| if (code >= 500) quit("D"," failed on DATA command"); 57 # qmail-remote.c:266| if (code >= 400) quit("Z"," failed on DATA command"); 58 # qmail-remote.c:271| if (code >= 500) quit("D"," failed after I sent the message"); 59 # qmail-remote.c:272| if (code >= 400) quit("Z"," failed after I sent the message"); 60 'data' => qr{(?: 61 (?:Error:)?[^ ]+[ ]failed[ ]on[ ]DATA[ ]command[.] 62 |(?:Error:)?[^ ]+[ ]failed[ ]after[ ]I[ ]sent[ ]the[ ]message[.] 63 ) 64 }x, 65 }; 66 state $rehost = qr{(?: 67 # qmail-remote.c:261| if (!flagbother) quit("DGiving up on ",""); 68 Giving[ ]up[ ]on[ ]([^ ]+[0-9a-zA-Z])[.]?\z 69 |Connected[ ]to[ ]([-0-9a-zA-Z.]+[0-9a-zA-Z])[ ] 70 |remote[ ]host[ ]([-0-9a-zA-Z.]+[0-9a-zA-Z])[ ]said: 71 ) 72 }x; 73 74 # qmail-send.c:922| ... (&dline[c],"I'm not going to try again; this message has been in the queue too long.\n")) nomem(); 75 state $hasexpired = 'this message has been in the queue too long.'; 76 # qmail-remote-fallback.patch 77 state $recommands = qr/Sorry, no SMTP connection got far enough; most progress was ([A-Z]{4}) /; 78 state $reisonhold = qr/\A[^ ]+ does not like recipient[.][ \t]+.+this message has been in the queue too long[.]\z/; 79 state $failonldap = { 80 # qmail-ldap-1.03-20040101.patch:19817 - 19866 81 'suspend' => ['Mailaddress is administrative?le?y disabled'], # 5.2.1 82 'userunknown' => ['Sorry, no mailbox here by that name'], # 5.1.1 83 'exceedlimit' => ['The message exeeded the maximum size the user accepts'], # 5.2.3 84 'systemerror' => [ 85 'Automatic homedir creator crashed', # 4.3.0 86 'Illegal value in LDAP attribute', # 5.3.5 87 'LDAP attribute is not given but mandatory', # 5.3.5 88 'Timeout while performing search on LDAP server', # 4.4.3 89 'Too many results returned but needs to be unique', # 5.3.5 90 'Permanent error while executing qmail-forward', # 5.4.4 91 'Temporary error in automatic homedir creation', # 4.3.0 or 5.3.0 92 'Temporary error while executing qmail-forward', # 4.4.4 93 'Temporary failure in LDAP lookup', # 4.4.3 94 'Unable to contact LDAP server', # 4.4.3 95 'Unable to login into LDAP server, bad credentials',# 4.4.3 96 ], 97 }; 98 state $messagesof = { 99 # qmail-local.c:589| strerr_die1x(100,"Sorry, no mailbox here by that name. (#5.1.1)"); 100 # qmail-remote.c:253| out("s"); outhost(); out(" does not like recipient.\n"); 101 'userunknown' => [ 102 'no mailbox here by that name', 103 'does not like recipient.', 104 ], 105 # error_str.c:192| X(EDQUOT,"disk quota exceeded") 106 'mailboxfull' => ['disk quota exceeded'], 107 # qmail-qmtpd.c:233| ... result = "Dsorry, that message size exceeds my databytes limit (#5.3.4)"; 108 # qmail-smtpd.c:391| ... out("552 sorry, that message size exceeds my databytes limit (#5.3.4)\r\n"); return; 109 'mesgtoobig' => ['Message size exceeds fixed maximum message size:'], 110 # qmail-remote.c:68| Sorry, I couldn't find any host by that name. (#4.1.2)\n"); zerodie(); 111 # qmail-remote.c:78| Sorry, I couldn't find any host named "); 112 'hostunknown' => ["Sorry, I couldn't find any host "], 113 'systemfull' => ['Requested action not taken: mailbox unavailable (not enough free space)'], 114 'systemerror' => [ 115 'bad interpreter: No such file or directory', 116 'system error', 117 'Unable to', 118 ], 119 'networkerror'=> [ 120 "Sorry, I wasn't able to establish an SMTP connection", 121 "Sorry, I couldn't find a mail exchanger or IP address", 122 "Sorry. Although I'm listed as a best-preference MX or A for that host", 123 ], 124 }; 125 126 my $dscontents = [__PACKAGE__->DELIVERYSTATUS]; 127 my $emailsteak = Sisimai::RFC5322->fillet($mbody, $rebackbone); 128 my $readcursor = 0; # (Integer) Points the current cursor position 129 my $recipients = 0; # (Integer) The number of 'Final-Recipient' header 130 my $v = undef; 131 132 for my $e ( split("\n", $emailsteak->[0]) ) { 133 # Read error messages and delivery status lines from the head of the email 134 # to the previous line of the beginning of the original message. 135 unless( $readcursor ) { 136 # Beginning of the bounce message or message/delivery-status part 137 $readcursor |= $indicators->{'deliverystatus'} if index($e, $startingof->{'message'}->[0]) == 0; 138 next; 139 } 140 next unless $readcursor & $indicators->{'deliverystatus'}; 141 next unless length $e; 142 143 # <kijitora@example.jp>: 144 # 192.0.2.153 does not like recipient. 145 # Remote host said: 550 5.1.1 <kijitora@example.jp>... User Unknown 146 # Giving up on 192.0.2.153. 147 $v = $dscontents->[-1]; 148 149 if( $e =~ /\A(?:To[ ]*:)?[<](.+[@].+)[>]:[ \t]*\z/ ) { 150 # <kijitora@example.jp>: 151 if( $v->{'recipient'} ) { 152 # There are multiple recipient addresses in the message body. 153 push @$dscontents, __PACKAGE__->DELIVERYSTATUS; 154 $v = $dscontents->[-1]; 155 } 156 $v->{'recipient'} = $1; 157 $recipients++; 158 159 } elsif( scalar @$dscontents == $recipients ) { 160 # Append error message 161 next unless length $e; 162 $v->{'diagnosis'} .= $e.' '; 163 $v->{'alterrors'} = $e if index($e, $startingof->{'error'}->[0]) == 0; 164 165 next if $v->{'rhost'}; 166 $v->{'rhost'} = $1 if $e =~ $rehost; 167 } 168 } 169 return undef unless $recipients; 170 171 for my $e ( @$dscontents ) { 172 $e->{'diagnosis'} = Sisimai::String->sweep($e->{'diagnosis'}); 173 174 if( ! $e->{'command'} ) { 175 # Get the SMTP command name for the session 176 SMTP: for my $r ( keys %$resmtp ) { 177 # Verify each regular expression of SMTP commands 178 next unless $e->{'diagnosis'} =~ $resmtp->{ $r }; 179 $e->{'command'} = uc $r; 180 last; 181 } 182 183 unless( $e->{'command'} ) { 184 # Verify each regular expression of patches 185 $e->{'command'} = uc $1 if $e->{'diagnosis'} =~ $recommands; 186 } 187 } 188 189 # Detect the reason of bounce 190 if( $e->{'command'} eq 'MAIL' ) { 191 # MAIL | Connected to 192.0.2.135 but sender was rejected. 192 $e->{'reason'} = 'rejected'; 193 194 } elsif( $e->{'command'} eq 'HELO' || $e->{'command'} eq 'EHLO' ) { 195 # HELO | Connected to 192.0.2.135 but my name was rejected. 196 $e->{'reason'} = 'blocked'; 197 198 } else { 199 # Try to match with each error message in the table 200 if( $e->{'diagnosis'} =~ $reisonhold ) { 201 # To decide the reason require pattern match with 202 # Sisimai::Reason::* modules 203 $e->{'reason'} = 'onhold'; 204 205 } else { 206 SESSION: for my $r ( keys %$messagesof ) { 207 # Verify each regular expression of session errors 208 if( $e->{'alterrors'} ) { 209 # Check the value of "alterrors" 210 next unless grep { index($e->{'alterrors'}, $_) > -1 } @{ $messagesof->{ $r } }; 211 $e->{'reason'} = $r; 212 } 213 last if $e->{'reason'}; 214 215 next unless grep { index($e->{'diagnosis'}, $_) > -1 } @{ $messagesof->{ $r } }; 216 $e->{'reason'} = $r; 217 last; 218 } 219 220 unless( $e->{'reason'} ) { 221 LDAP: for my $r ( keys %$failonldap ) { 222 # Verify each regular expression of LDAP errors 223 next unless grep { index($e->{'diagnosis'}, $_) > -1 } @{ $failonldap->{ $r } }; 224 $e->{'reason'} = $r; 225 last; 226 } 227 } 228 229 unless( $e->{'reason'} ) { 230 $e->{'reason'} = 'expired' if index($e->{'diagnosis'}, $hasexpired) > -1; 231 } 232 } 233 } 234 $e->{'command'} ||= ''; 235 $e->{'status'} = Sisimai::SMTP::Status->find($e->{'diagnosis'}) || ''; 236 } 237 return { 'ds' => $dscontents, 'rfc822' => $emailsteak->[1] }; 238} 239 2401; 241__END__ 242 243=encoding utf-8 244 245=head1 NAME 246 247Sisimai::Lhost::qmail - bounce mail parser class for C<qmail>. 248 249=head1 SYNOPSIS 250 251 use Sisimai::Lhost::qmail; 252 253=head1 DESCRIPTION 254 255Sisimai::Lhost::qmail parses a bounce email which created by C<qmail>. 256Methods in the module are called from only Sisimai::Message. 257 258=head1 CLASS METHODS 259 260=head2 C<B<description()>> 261 262C<description()> returns description string of this module. 263 264 print Sisimai::Lhost::qmail->description; 265 266=head2 C<B<make(I<header data>, I<reference to body string>)>> 267 268C<make()> method parses a bounced email and return results as a array reference. 269See Sisimai::Message for more details. 270 271=head1 AUTHOR 272 273azumakuniyuki 274 275=head1 COPYRIGHT 276 277Copyright (C) 2014-2020 azumakuniyuki, All rights reserved. 278 279=head1 LICENSE 280 281This software is distributed under The BSD 2-Clause License. 282 283=cut 284