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