File Coverage

lib/Sisimai/Lhost/AmazonSES.pm
Criterion Covered Total %
statement 100 112 89.2
branch 27 40 67.5
condition 6 9 66.6
subroutine 6 6 100.0
pod 2 2 100.0
total 141 169 83.4


line stmt bran cond sub pod time code
1             package Sisimai::Lhost::AmazonSES;
2 38     38   4295 use parent 'Sisimai::Lhost';
  38         77  
  38         258  
3 38     38   2988 use v5.26;
  38         129  
4 38     38   219 use strict;
  38         69  
  38         1255  
5 38     38   187 use warnings;
  38         90  
  38         66395  
6              
7             # ---------------------------------------------------------------------------------------------
8             # "notificationType": "Bounce"
9             # https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html#bounce-object
10             #
11             # Bounce types
12             # The bounce object contains a bounce type of Undetermined, Permanent, or Transient. The
13             # Permanent and Transient bounce types can also contain one of several bounce subtypes.
14             #
15             # When you receive a bounce notification with a bounce type of Transient, you might be
16             # able to send email to that recipient in the future if the issue that caused the message
17             # to bounce is resolved.
18             #
19             # When you receive a bounce notification with a bounce type of Permanent, it's unlikely
20             # that you'll be able to send email to that recipient in the future. For this reason, you
21             # should immediately remove the recipient whose address produced the bounce from your
22             # mailing lists.
23             #
24             # "bounceType"/"bounceSubType" "Desription"
25             # Undetermined/Undetermined -- The bounce message didn't contain enough information for
26             # Amazon SES to determine the reason for the bounce.
27             #
28             # Permanent/General ---------- When you receive this type of bounce notification, you should
29             # immediately remove the recipient's email address from your
30             # mailing list.
31             # Permanent/NoEmail ---------- It was not possible to retrieve the recipient email address
32             # from the bounce message.
33             # Permanent/Suppressed ------- The recipient's email address is on the Amazon SES suppression
34             # list because it has a recent history of producing hard bounces.
35             # Permanent/OnAccountSuppressionList
36             # Amazon SES has suppressed sending to this address because it
37             # is on the account-level suppression list.
38             #
39             # Transient/General ---------- You might be able to send a message to the same recipient
40             # in the future if the issue that caused the message to bounce
41             # is resolved.
42             # Transient/MailboxFull ------ the recipient's inbox was full.
43             # Transient/MessageTooLarge -- message you sent was too large
44             # Transient/ContentRejected -- message you sent contains content that the provider doesn't allow
45             # Transient/AttachmentRejected the message contained an unacceptable attachment
46             state $ReasonPair = {
47             "Suppressed" => "suppressed",
48             "OnAccountSuppressionList" => "suppressed",
49             "General" => "onhold",
50             "MailboxFull" => "mailboxfull",
51             "MessageTooLarge" => "emailtoolarge",
52             "ContentRejected" => "contenterror",
53             "AttachmentRejected" => "securityerror",
54             };
55              
56             # https://aws.amazon.com/ses/
57 1     1 1 4 sub description { 'Amazon SES(Sending): https://aws.amazon.com/ses/' };
58             sub inquire {
59             # Detect an error from Amazon SES
60             # @param [Hash] mhead Message headers of a bounce email (JSON)
61             # @param [String] mbody Message body of a bounce email
62             # @return [Hash] Bounce data list and message/rfc822 part
63             # @return [undef] failed to decode or the arguments are missing
64             # @see https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html
65             # @since v4.0.2
66 939     939 1 5337 my $class = shift;
67 939   100     3290 my $mhead = shift // return undef;
68 938 100 100     3294 my $mbody = shift // return undef; return undef unless index($$mbody, "{") > -1;
  937         4110  
69 47 100       244 return undef unless exists $mhead->{'x-amz-sns-message-id'};
70 25 50       11749 return undef unless $mhead->{'x-amz-sns-message-id'};
71              
72 25         59 my $proceedsto = 0;
73 25         13115 my $sespayload = $$mbody;
74 25         43 while(1) {
75             # Remote the following string begins with "--"
76             # --
77             # If you wish to stop receiving notifications from this topic, please click or visit the link below to unsubscribe:
78             # https://sns.us-west-2.amazonaws.com/unsubscribe.html?SubscriptionArn=arn:aws:sns:us-west-2:1...
79 25         71 my $p1 = index($$mbody, "\n\n--\n");
80 25 100       102 $sespayload = substr($$mbody, 0, $p1) if $p1 > 0;
81 25         146 $sespayload =~ s/!\n //g;
82 25         88 my $p2 = index($sespayload, '"Message"');
83              
84 25 100       85 if( $p2 > 0 ) {
85             # The JSON included in the email is a format like the following:
86             # {
87             # "Type" : "Notification",
88             # "MessageId" : "02f86d9b-eecf-573d-b47d-3d1850750c30",
89             # "TopicArn" : "arn:aws:sns:us-west-2:123456789012:SES-EJ-B",
90             # "Message" : "{\"notificationType\"...
91 5         180 $sespayload =~ s/\\//g;
92 5         14 my $p3 = index($sespayload, "{", $p2 + 9);
93 5         11 my $p4 = index($sespayload, "\n", $p2 + 9);
94 5         16 $sespayload = substr($sespayload, $p3, $p4 - $p3);
95 5         27 $sespayload =~ s/,$//g;
96 5         31 $sespayload =~ s/"$//g;
97             }
98 25 50 33     193 last if index($sespayload, "notificationType") < 0 || index($sespayload, "{") != 0;
99 25 50       204 last if substr($sespayload, -1, 1) ne "}";
100 25         41 $proceedsto = 1; last;
  25         42  
101             }
102 25 50       51 return undef unless $proceedsto;
103              
104             # Load as JSON string and decode
105 25         121 require JSON;
106 25         45 my $jsonobject = undef; eval { $jsonobject = JSON->new->decode($sespayload) };
  25         46  
  25         1259  
107 25 50       80 if( $@ ) {
108             # Something wrong in decoding JSON
109 0         0 warn sprintf(" ***warning: Failed to decode JSON: %s", $@);
110 0         0 return undef;
111             }
112 25 50       65 return undef unless exists $jsonobject->{'notificationType'};
113              
114 25         103 require Sisimai::String;
115 25         92 require Sisimai::RFC1123;
116 25         73 require Sisimai::SMTP::Reply;
117 25         105 require Sisimai::SMTP::Status;
118 25         86 require Sisimai::SMTP::Command;
119              
120 25         131 my $dscontents = [__PACKAGE__->DELIVERYSTATUS];
121 25         38 my $recipients = 0;
122 25   50     75 my $whatnotify = substr($jsonobject->{"notificationType"}, 0, 1) || "";
123 25         47 my $v = $dscontents->[-1];
124              
125 25 100       93 if( $whatnotify eq "B" ) {
    100          
    50          
126             # "notificationType":"Bounce"
127 10         23 my $p = $jsonobject->{"bounce"};
128 10 50       45 my $r = $p->{"bounceType"} eq "Permanent" ? "5" : "4";
129              
130 10         30 for my $e ( $p->{"bouncedRecipients"}->@* ) {
131             # {"emailAddress":"neko@example.jp", "action":"failed", "status":"5.1.1", "diagnosticCode": "..."}
132 10 50       28 if( $v->{"recipient"} ) {
133             # There are multiple recipient addresses in the message body.
134 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
135 0         0 $v = $dscontents->[-1];
136             }
137 10         20 $v->{"recipient"} = $e->{"emailAddress"};
138 10         81 $v->{"diagnosis"} = Sisimai::String->sweep($e->{"diagnosticCode"});
139 10         106 $v->{"command"} = Sisimai::SMTP::Command->find($v->{"diagnosis"});
140 10         48 $v->{"action"} = $e->{"action"};
141 10         79 $v->{"status"} = Sisimai::SMTP::Status->find($v->{"diagnosis"}, $r);
142 10         107 $v->{"replycode"} = Sisimai::SMTP::Reply->find($v->{"diagnosis"}, $v->{"status"});
143 10         33 $v->{"date"} = $p->{"timestamp"};
144 10         90 $v->{"lhost"} = Sisimai::RFC1123->find($p->{"reportingMTA"});
145 10         17 $recipients++;
146              
147 10         43 for my $f ( keys %$ReasonPair ) {
148             # Try to find the bounce reason by "bounceSubType"
149 70 50       152 next unless $ReasonPair->{ $f } eq $p->{"bounceSubType"};
150 0         0 $v->{"reason"} = $f; last;
  0         0  
151             }
152             }
153             } elsif( $whatnotify eq "C" ) {
154             # "notificationType":"Complaint"
155 5         26 my $p = $jsonobject->{"complaint"}; for my $e ( $p->{"complainedRecipients"}->@* ) {
  5         27  
156             # {"emailAddress":"neko@example.jp"}
157 5 50       14 if( $v->{"recipient"} ) {
158             # There are multiple recipient addresses in the message body.
159 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
160 0         0 $v = $dscontents->[-1];
161             }
162 5         10 $v->{"recipient"} = $e->{"emailAddress"};
163 5         10 $v->{"reason"} = "feedback";
164 5         9 $v->{"feedbacktype"} = $p->{"complaintFeedbackType"};
165 5         9 $v->{"date"} = $p->{"timestamp"};
166 5         13 $v->{"diagnosis"} = sprintf(qq|{"feedbackid":"%s", "useragent":"%s"}|, $p->{"feedbackId"}, $p->{"userAgent"});
167 5         11 $recipients++;
168             }
169             } elsif( $whatnotify eq "D" ) {
170             # "notificationType":"Delivery"
171 10         18 my $p = $jsonobject->{"delivery"}; for my $e ( $p->{"recipients"}->@* ) {
  10         30  
172             # {"recipients":["neko@example.jp"]}
173 10 50       24 if( $v->{"recipient"} ) {
174             # There are multiple recipient addresses in the message body.
175 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
176 0         0 $v = $dscontents->[-1];
177             }
178 10         17 $v->{"recipient"} = $e;
179 10         17 $v->{"reason"} = "delivered";
180 10         17 $v->{"action"} = "delivered";
181 10         17 $v->{"date"} = $p->{"timestamp"};
182 10         16 $v->{"lhost"} = $p->{"reportingMTA"};
183 10         17 $v->{"diagnosis"} = $p->{"smtpResponse"};
184 10         81 $v->{"status"} = Sisimai::SMTP::Status->find($v->{"diagnosis"}, "2");
185 10         69 $v->{"replycode"} = Sisimai::SMTP::Reply->find($v->{"diagnosis"}, "2");
186 10         22 $recipients++;
187             }
188             } else {
189             # Unknown "notificationType" value
190 0         0 warn sprintf(" ***warning: There is no notificationType field or unknown type of notificationType field");
191 0         0 return undef;
192             }
193 25 50       95 return undef unless $recipients;
194              
195             # Time::Piece->strptime() cannot parse "2016-11-25T01:49:01.000Z" format
196 25         41 for my $e ( @$dscontents ) { s/T/ /, s/[.]\d{3}Z$// for $e->{'date'} }
  25         251  
197              
198             # Generate pseudo email headers as the original message
199 25         40 my $cv = "";
200 25         53 my $ch = ["date", "subject"];
201 25         46 my $or = $jsonobject->{'mail'};
202              
203 25         65 map { $cv .= sprintf("%s: %s\n", $_->{"name"}, $_->{"value"}) } $or->{"headers"}->@*;
  150         332  
204 25 100       36 map { $cv .= sprintf("%s: %s\n", ucfirst($_), $or->{"commonHeaders"}->{ $_ }) if exists $or->{"commonHeaders"}->{ $_ } } @$ch;
  50         225  
205              
206 25         338 return {"ds" => $dscontents, "rfc822" => $cv};
207             }
208              
209             1;
210             __END__