File Coverage

lib/Sisimai/Lhost/AmazonSES.pm
Criterion Covered Total %
statement 149 162 91.9
branch 72 94 76.6
condition 26 50 52.0
subroutine 6 6 100.0
pod 2 2 100.0
total 255 314 81.2


line stmt bran cond sub pod time code
1             package Sisimai::Lhost::AmazonSES;
2 28     28   6218 use parent 'Sisimai::Lhost';
  28         66  
  28         151  
3 28     28   1693 use feature ':5.10';
  28         58  
  28         1941  
4 28     28   162 use strict;
  28         63  
  28         677  
5 28     28   153 use warnings;
  28         60  
  28         61379  
6              
7             # https://aws.amazon.com/ses/
8 2     2 1 1219 sub description { 'Amazon SES(Sending): https://aws.amazon.com/ses/' };
9             sub make {
10             # Detect an error from Amazon SES
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             # @since v4.0.2
16 532     532 1 1228 my $class = shift;
17 532   100     1289 my $mhead = shift // return undef;
18 531   50     1204 my $mbody = shift // return undef;
19              
20 531         995 state $indicators = __PACKAGE__->INDICATORS;
21 531         898 state $rebackbone = qr|^content-type:[ ]message/rfc822|m;
22 531         813 state $startingof = {
23             'message' => ['The following message to <', 'An error occurred while trying to deliver the mail '],
24             };
25 531         709 state $messagesof = { 'expired' => ['Delivery expired'] };
26              
27 531         1497 my $dscontents = [__PACKAGE__->DELIVERYSTATUS];
28 531         1014 my $recipients = 0; # (Integer) The number of 'Final-Recipient' header
29              
30 531 100       1882 if( index($$mbody, '{') == 0 ) {
31             # The message body is JSON string
32 25 50       135 return undef unless exists $mhead->{'x-amz-sns-message-id'};
33 25 50       111 return undef unless $mhead->{'x-amz-sns-message-id'};
34              
35             # https://docs.aws.amazon.com/en_us/ses/latest/DeveloperGuide/notification-contents.html
36 25         306 my $bouncetype = {
37             'Permanent' => { 'General' => '', 'NoEmail' => '', 'Suppressed' => '' },
38             'Transient' => {
39             'General' => '',
40             'MailboxFull' => 'mailboxfull',
41             'MessageTooLarge' => 'mesgtoobig',
42             'ContentRejected' => '',
43             'AttachmentRejected' => '',
44             },
45             };
46 25         55 my $jsonstring = '';
47 25         42 my $foldedline = 0;
48 25         47 my $sespayload = undef;
49              
50 25         287 for my $e ( split(/\n/, $$mbody) ) {
51             # Find JSON string from the message body
52 130 100       303 next unless length $e;
53 110 100       260 last if $e eq '--';
54              
55 90 100       193 substr($e, 0, 1, '') if $foldedline; # The line starts with " ", continued from !\n.
56 90         121 $foldedline = 0;
57              
58 90 100       188 if( substr($e, -1, 1) eq '!' ) {
59             # ... long long line ...![\n]
60 15         71 substr($e, -1, 1, '');
61 15         29 $foldedline = 1;
62             }
63 90         156 $jsonstring .= $e;
64             }
65              
66 25         1168 require JSON;
67 25         9511 eval {
68 25         324 my $jsonparser = JSON->new;
69 25         1002 my $jsonobject = $jsonparser->decode($jsonstring);
70              
71 25 100       109 if( exists $jsonobject->{'Message'} ) {
72             # 'Message' => '{"notificationType":"Bounce",...
73 5         223 $sespayload = $jsonparser->decode($jsonobject->{'Message'});
74              
75             } else {
76             # 'mail' => { 'sourceArn' => '...',... }, 'bounce' => {...},
77 20         128 $sespayload = $jsonobject;
78             }
79             };
80 25 50       91 if( $@ ) {
81             # Something wrong in decoding JSON
82 0         0 warn sprintf(" ***warning: Failed to decode JSON: %s", $@);
83 0         0 return undef;
84             }
85 25 50       109 return undef unless exists $sespayload->{'notificationType'};
86              
87 25         44 my $rfc822head = {}; # (Hash) Check flags for headers in RFC822 part
88 25         101 my $labeltable = {
89             'Bounce' => 'bouncedRecipients',
90             'Complaint' => 'complainedRecipients',
91             };
92 25         50 my $p = $sespayload;
93 25         42 my $v = undef;
94              
95 25 100 100     147 if( $p->{'notificationType'} eq 'Bounce' || $p->{'notificationType'} eq 'Complaint' ) {
    50          
96             # { "notificationType":"Bounce", "bounce": { "bounceType":"Permanent",...
97 15         54 my $o = $p->{ lc $p->{'notificationType'} };
98 15   50     76 my $r = $o->{ $labeltable->{ $p->{'notificationType'} } } || [];
99              
100 15         40 for my $e ( @$r ) {
101             # 'bouncedRecipients' => [ { 'emailAddress' => 'bounce@si...' }, ... ]
102             # 'complainedRecipients' => [ { 'emailAddress' => 'complaint@si...' }, ... ]
103 15 50       88 next unless Sisimai::RFC5322->is_emailaddress($e->{'emailAddress'});
104              
105 15         101 $v = $dscontents->[-1];
106 15 50       53 if( $v->{'recipient'} ) {
107             # There are multiple recipient addresses in the message body.
108 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
109 0         0 $v = $dscontents->[-1];
110             }
111 15         27 $recipients++;
112 15         42 $v->{'recipient'} = $e->{'emailAddress'};
113              
114 15 100       41 if( $p->{'notificationType'} eq 'Bounce' ) {
115             # 'bouncedRecipients => [ {
116             # 'emailAddress' => 'bounce@simulator.amazonses.com',
117             # 'action' => 'failed',
118             # 'status' => '5.1.1',
119             # 'diagnosticCode' => 'smtp; 550 5.1.1 user unknown'
120             # }, ... ]
121 10         25 $v->{'action'} = $e->{'action'};
122 10         28 $v->{'status'} = $e->{'status'};
123              
124 10 50       59 if( $e->{'diagnosticCode'} =~ /\A(.+?);[ ]*(.+)\z/ ) {
125             # Diagnostic-Code: SMTP; 550 5.1.1 ... User Unknown
126 10         42 $v->{'spec'} = uc $1;
127 10         31 $v->{'diagnosis'} = $2;
128              
129             } else {
130 0         0 $v->{'diagnosis'} = $e->{'diagnosticCode'};
131             }
132              
133             # 'reportingMTA' => 'dsn; a27-23.smtp-out.us-west-2.amazonses.com',
134 10 50       65 $v->{'lhost'} = $1 if $o->{'reportingMTA'} =~ /\Adsn;[ ](.+)\z/;
135              
136 10 50 33     129 if( exists $bouncetype->{ $o->{'bounceType'} } &&
137             exists $bouncetype->{ $o->{'bounceType'} }->{ $o->{'bounceSubType'} } ) {
138             # 'bounce' => {
139             # 'bounceType' => 'Permanent',
140             # 'bounceSubType' => 'General'
141             # },
142 10         34 $v->{'reason'} = $bouncetype->{ $o->{'bounceType'} }->{ $o->{'bounceSubType'} };
143             }
144             } else {
145             # 'complainedRecipients' => [ {
146             # 'emailAddress' => 'complaint@simulator.amazonses.com' }, ... ],
147 5         16 $v->{'reason'} = 'feedback';
148 5   50     32 $v->{'feedbacktype'} = $o->{'complaintFeedbackType'} || '';
149             }
150 15   33     148 ($v->{'date'} = $o->{'timestamp'} || $p->{'mail'}->{'timestamp'}) =~ s/[.]\d+Z\z//;
151             }
152             } elsif( $p->{'notificationType'} eq 'Delivery' ) {
153             # { "notificationType":"Delivery", "delivery": { ...
154 10         22 my $o = $p->{'delivery'};
155 10   50     34 my $r = $o->{'recipients'} || [];
156              
157 10         28 for my $e ( @$r ) {
158             # 'delivery' => {
159             # 'timestamp' => '2016-11-23T12:01:03.512Z',
160             # 'processingTimeMillis' => 3982,
161             # 'reportingMTA' => 'a27-29.smtp-out.us-west-2.amazonses.com',
162             # 'recipients' => [
163             # 'success@simulator.amazonses.com'
164             # ],
165             # 'smtpResponse' => '250 2.6.0 Message received'
166             # },
167 10 50       54 next unless Sisimai::RFC5322->is_emailaddress($e);
168              
169 10         64 $v = $dscontents->[-1];
170 10 50       38 if( $v->{'recipient'} ) {
171             # There are multiple recipient addresses in the message body.
172 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
173 0         0 $v = $dscontents->[-1];
174             }
175 10         24 $recipients++;
176 10         24 $v->{'recipient'} = $e;
177 10   50     36 $v->{'lhost'} = $o->{'reportingMTA'} || '';
178 10   50     43 $v->{'diagnosis'} = $o->{'smtpResponse'} || '';
179 10   50     62 $v->{'status'} = Sisimai::SMTP::Status->find($v->{'diagnosis'}) || '';
180 10   50     96 $v->{'replycode'} = Sisimai::SMTP::Reply->find($v->{'diagnosis'}) || '';
181 10         47 $v->{'reason'} = 'delivered';
182 10         33 $v->{'action'} = 'delivered';
183 10   33     114 ($v->{'date'} = $o->{'timestamp'} || $p->{'mail'}->{'timestamp'}) =~ s/[.]\d+Z\z//;
184             }
185             } else {
186             # The value of "notificationType" is not any of "Bounce", "Complaint",
187             # or "Delivery".
188 0         0 return undef;
189             }
190 25 50       113 return undef unless $recipients;
191              
192 25 50       74 if( exists $p->{'mail'}->{'headers'} ) {
193             # "headersTruncated":false,
194             # "headers":[ { ...
195 25         56 for my $e ( @{ $p->{'mail'}->{'headers'} } ) {
  25         88  
196             # 'headers' => [ { 'name' => 'From', 'value' => 'neko@nyaan.jp' }, ... ],
197 150 100       476 next unless $e->{'name'} =~ /\A(?:From|To|Subject|Message-ID|Date)\z/;
198 75         197 $rfc822head->{ lc $e->{'name'} } = $e->{'value'};
199             }
200             }
201              
202 25 50       131 unless( $rfc822head->{'message-id'} ) {
203             # Try to get the value of "Message-Id".
204             # 'messageId' => '01010157e48f9b9b-891e9a0e-9c9d-4773-9bfe-608f2ef4756d-000000'
205 25 50       91 $rfc822head->{'message-id'} = $p->{'mail'}->{'messageId'} if $p->{'mail'}->{'messageId'};
206             }
207 25         370 return { 'ds' => $dscontents, 'rfc822' => $rfc822head };
208              
209             } else {
210             # The message body is an email
211             # 'from' => qr/\AMAILER-DAEMON[@]email[-]bounces[.]amazonses[.]com\z/,
212             # 'subject' => qr/\ADelivery Status Notification [(]Failure[)]\z/,
213 506   100     1967 my $xmail = $mhead->{'x-mailer'} || '';
214 506 100       1536 return undef if index($xmail, 'Amazon WorkMail') > -1;
215              
216             # X-SenderID: Sendmail Sender-ID Filter v1.0.0 nijo.example.jp p7V3i843003008
217             # X-Original-To: 000001321defbd2a-788e31c8-2be1-422f-a8d4-cf7765cc9ed7-000000@email-bounces.amazonses.com
218             # X-AWS-Outgoing: 199.255.192.156
219             # X-SES-Outgoing: 2016.10.12-54.240.27.6
220 470         745 my $match = 0;
221 470 100 50     1136 $match ||= 1 if $mhead->{'x-aws-outgoing'};
222 470 100 50     1638 $match ||= 1 if $mhead->{'x-ses-outgoing'};
223 470 100       1986 return undef unless $match;
224              
225 129         2128 require Sisimai::RFC1894;
226 129         633 my $fieldtable = Sisimai::RFC1894->FIELDTABLE;
227 129         317 my $permessage = {}; # (Hash) Store values of each Per-Message field
228 129         279 my $readcursor = 0; # (Integer) Points the current cursor position
229 129         632 my $emailsteak = Sisimai::RFC5322->fillet($mbody, $rebackbone);
230 129         413 my $v = undef;
231 129         372 my $p = '';
232              
233 129         2100 for my $e ( split("\n", $emailsteak->[0]) ) {
234             # Read each line between the start of the message and the start of rfc822 part.
235 5423 100       7730 unless( $readcursor ) {
236             # Beginning of the bounce message or message/delivery-status part
237 2298 100 100     7097 if( index($e, $startingof->{'message'}->[0]) == 0 ||
238             index($e, $startingof->{'message'}->[1]) == 0 ) {
239 82         201 $readcursor |= $indicators->{'deliverystatus'};
240 82         108 next;
241             }
242             }
243 5341 100       8206 next unless $readcursor & $indicators->{'deliverystatus'};
244 3125 100       4608 next unless length $e;
245              
246 2507 100       4108 if( my $f = Sisimai::RFC1894->match($e) ) {
247             # $e matched with any field defined in RFC3464
248 426 50       1381 next unless my $o = Sisimai::RFC1894->field($e);
249 426         703 $v = $dscontents->[-1];
250              
251 426 100       1099 if( $o->[-1] eq 'addr' ) {
    100          
252             # Final-Recipient: rfc822; kijitora@example.jp
253             # X-Actual-Recipient: rfc822; kijitora@example.co.jp
254 82 50       409 if( $o->[0] eq 'final-recipient' ) {
255             # Final-Recipient: rfc822; kijitora@example.jp
256 82 50       302 if( $v->{'recipient'} ) {
257             # There are multiple recipient addresses in the message body.
258 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
259 0         0 $v = $dscontents->[-1];
260             }
261 82         167 $v->{'recipient'} = $o->[2];
262 82         197 $recipients++;
263              
264             } else {
265             # X-Actual-Recipient: rfc822; kijitora@example.co.jp
266 0         0 $v->{'alias'} = $o->[2];
267             }
268             } elsif( $o->[-1] eq 'code' ) {
269             # Diagnostic-Code: SMTP; 550 5.1.1 ... User Unknown
270 82         416 $v->{'spec'} = $o->[1];
271 82         257 $v->{'diagnosis'} = $o->[2];
272              
273             } else {
274             # Other DSN fields defined in RFC3464
275 262 50       575 next unless exists $fieldtable->{ $o->[0] };
276 262         576 $v->{ $fieldtable->{ $o->[0] } } = $o->[2];
277              
278 262 100       628 next unless $f == 1;
279 82         322 $permessage->{ $fieldtable->{ $o->[0] } } = $o->[2];
280             }
281             } else {
282             # Continued line of the value of Diagnostic-Code field
283 2081 100       3816 next unless index($p, 'Diagnostic-Code:') == 0;
284 10 50       94 next unless $e =~ /\A[ \t]+(.+)\z/;
285 10         45 $v->{'diagnosis'} .= ' '.$1;
286             }
287             } continue {
288             # Save the current line for the next loop
289 5423         7522 $p = $e;
290             }
291 129 100       1113 return undef unless $recipients;
292              
293 82         271 for my $e ( @$dscontents ) {
294             # Set default values if each value is empty.
295 82   33     447 $e->{'lhost'} ||= $permessage->{'rhost'};
296 82   0     471 $e->{ $_ } ||= $permessage->{ $_ } || '' for keys %$permessage;
      33        
297              
298 82         250 $e->{'diagnosis'} =~ y/\n/ /;
299 82         913 $e->{'diagnosis'} = Sisimai::String->sweep($e->{'diagnosis'});
300 82 100       552 if( $e->{'status'} =~ /\A[45][.][01][.]0\z/ ) {
301             # Get other D.S.N. value from the error message
302             # 5.1.0 - Unknown address error 550-'5.7.1 ...
303 22         66 my $errormessage = $e->{'diagnosis'};
304 22 100       188 $errormessage = $1 if $e->{'diagnosis'} =~ /["'](\d[.]\d[.]\d.+)['"]/;
305 22   33     134 $e->{'status'} = Sisimai::SMTP::Status->find($errormessage) || $e->{'status'};
306             }
307              
308 82         360 SESSION: for my $r ( keys %$messagesof ) {
309             # Verify each regular expression of session errors
310 82 50       131 next unless grep { index($e->{'diagnosis'}, $_) > -1 } @{ $messagesof->{ $r } };
  82         521  
  82         240  
311 0         0 $e->{'reason'} = $r;
312 0         0 last;
313             }
314             }
315 82         747 return { 'ds' => $dscontents, 'rfc822' => $emailsteak->[1] };
316             }
317             }
318              
319             1;
320             __END__