File Coverage

lib/Sisimai/Lhost/AmazonSES.pm
Criterion Covered Total %
statement 99 111 89.1
branch 27 40 67.5
condition 6 9 66.6
subroutine 6 6 100.0
pod 2 2 100.0
total 140 168 83.3


line stmt bran cond sub pod time code
1             package Sisimai::Lhost::AmazonSES;
2 43     43   2864 use parent 'Sisimai::Lhost';
  43         70  
  43         237  
3 43     43   2762 use v5.26;
  43         114  
4 43     43   160 use strict;
  43         51  
  43         998  
5 43     43   160 use warnings;
  43         95  
  43         52348  
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 2 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 969     969 1 2989 my $class = shift;
67 969   100     2160 my $mhead = shift // return undef;
68 968 100 100     2236 my $mbody = shift // return undef; return undef unless index($$mbody, "{") > -1;
  967         2698  
69 47 100       169 return undef unless exists $mhead->{'x-amz-sns-message-id'};
70 25 50       72 return undef unless $mhead->{'x-amz-sns-message-id'};
71              
72 25         34 my $proceedsto = 0;
73 25         70 my $sespayload = $$mbody;
74 25         33 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         55 my $p1 = index($$mbody, "\n\n--\n");
80 25 100       80 $sespayload = substr($$mbody, 0, $p1) if $p1 > 0;
81 25         112 $sespayload =~ s/!\n //g;
82 25         67 my $p2 = index($sespayload, '"Message"');
83              
84 25 100       61 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         149 $sespayload =~ s/\\//g;
92 5         15 my $p3 = index($sespayload, "{", $p2 + 9);
93 5         12 my $p4 = index($sespayload, "\n", $p2 + 9);
94 5         13 $sespayload = substr($sespayload, $p3, $p4 - $p3);
95 5         24 $sespayload =~ s/,$//g;
96 5         35 $sespayload =~ s/"$//g;
97             }
98 25 50 33     118 last if index($sespayload, "notificationType") < 0 || index($sespayload, "{") != 0;
99 25 50       56 last if substr($sespayload, -1, 1) ne "}";
100 25         50 $proceedsto = 1; last;
  25         39  
101             }
102 25 50       50 return undef unless $proceedsto;
103              
104             # Load as JSON string and decode
105 25         86 require JSON;
106 25         31 my $jsonobject = undef; eval { $jsonobject = JSON->new->decode($sespayload) };
  25         47  
  25         1000  
107 25 50       61 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       56 return undef unless exists $jsonobject->{'notificationType'};
113              
114 25         103 require Sisimai::RFC1123;
115 25         62 require Sisimai::SMTP::Reply;
116 25         67 require Sisimai::SMTP::Status;
117 25         55 require Sisimai::SMTP::Command;
118              
119 25         89 my $dscontents = [__PACKAGE__->DELIVERYSTATUS];
120 25         41 my $recipients = 0;
121 25   50     65 my $whatnotify = substr($jsonobject->{"notificationType"}, 0, 1) || "";
122 25         36 my $v = $dscontents->[-1];
123              
124 25 100       100 if( $whatnotify eq "B" ) {
    100          
    50          
125             # "notificationType":"Bounce"
126 10         21 my $p = $jsonobject->{"bounce"};
127 10 50       27 my $r = $p->{"bounceType"} eq "Permanent" ? "5" : "4";
128              
129 10         25 for my $e ( $p->{"bouncedRecipients"}->@* ) {
130             # {"emailAddress":"neko@example.jp", "action":"failed", "status":"5.1.1", "diagnosticCode": "..."}
131 10 50       25 if( $v->{"recipient"} ) {
132             # There are multiple recipient addresses in the message body.
133 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
134 0         0 $v = $dscontents->[-1];
135             }
136 10         19 $v->{"recipient"} = $e->{"emailAddress"};
137 10         17 $v->{"diagnosis"} = $e->{"diagnosticCode"};
138 10         73 $v->{"command"} = Sisimai::SMTP::Command->find($v->{"diagnosis"});
139 10         23 $v->{"action"} = $e->{"action"};
140 10         54 $v->{"status"} = Sisimai::SMTP::Status->find($v->{"diagnosis"}, $r);
141 10         63 $v->{"replycode"} = Sisimai::SMTP::Reply->find($v->{"diagnosis"}, $v->{"status"});
142 10         22 $v->{"date"} = $p->{"timestamp"};
143 10         61 $v->{"lhost"} = Sisimai::RFC1123->find($p->{"reportingMTA"});
144 10         13 $recipients++;
145              
146 10         60 for my $f ( keys %$ReasonPair ) {
147             # Try to find the bounce reason by "bounceSubType"
148 70 50       148 next unless $ReasonPair->{ $f } eq $p->{"bounceSubType"};
149 0         0 $v->{"reason"} = $f; last;
  0         0  
150             }
151             }
152             } elsif( $whatnotify eq "C" ) {
153             # "notificationType":"Complaint"
154 5         27 my $p = $jsonobject->{"complaint"}; for my $e ( $p->{"complainedRecipients"}->@* ) {
  5         13  
155             # {"emailAddress":"neko@example.jp"}
156 5 50       15 if( $v->{"recipient"} ) {
157             # There are multiple recipient addresses in the message body.
158 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
159 0         0 $v = $dscontents->[-1];
160             }
161 5         12 $v->{"recipient"} = $e->{"emailAddress"};
162 5         9 $v->{"reason"} = "feedback";
163 5         11 $v->{"feedbacktype"} = $p->{"complaintFeedbackType"};
164 5         9 $v->{"date"} = $p->{"timestamp"};
165 5         11 $v->{"diagnosis"} = sprintf(qq|{"feedbackid":"%s", "useragent":"%s"}|, $p->{"feedbackId"}, $p->{"userAgent"});
166 5         8 $recipients++;
167             }
168             } elsif( $whatnotify eq "D" ) {
169             # "notificationType":"Delivery"
170 10         14 my $p = $jsonobject->{"delivery"}; for my $e ( $p->{"recipients"}->@* ) {
  10         27  
171             # {"recipients":["neko@example.jp"]}
172 10 50       20 if( $v->{"recipient"} ) {
173             # There are multiple recipient addresses in the message body.
174 0         0 push @$dscontents, __PACKAGE__->DELIVERYSTATUS;
175 0         0 $v = $dscontents->[-1];
176             }
177 10         18 $v->{"recipient"} = $e;
178 10         18 $v->{"reason"} = "delivered";
179 10         14 $v->{"action"} = "delivered";
180 10         14 $v->{"date"} = $p->{"timestamp"};
181 10         14 $v->{"lhost"} = $p->{"reportingMTA"};
182 10         18 $v->{"diagnosis"} = $p->{"smtpResponse"};
183 10         63 $v->{"status"} = Sisimai::SMTP::Status->find($v->{"diagnosis"}, "2");
184 10         54 $v->{"replycode"} = Sisimai::SMTP::Reply->find($v->{"diagnosis"}, "2");
185 10         41 $recipients++;
186             }
187             } else {
188             # Unknown "notificationType" value
189 0         0 warn sprintf(" ***warning: There is no notificationType field or unknown type of notificationType field");
190 0         0 return undef;
191             }
192 25 50       50 return undef unless $recipients;
193              
194             # Time::Piece->strptime() cannot parse "2016-11-25T01:49:01.000Z" format
195 25         39 for my $e ( @$dscontents ) { s/T/ /, s/[.]\d{3}Z$// for $e->{'date'} }
  25         242  
196              
197             # Generate pseudo email headers as the original message
198 25         35 my $cv = "";
199 25         63 my $ch = ["date", "subject"];
200 25         45 my $or = $jsonobject->{'mail'};
201              
202 25         68 map { $cv .= sprintf("%s: %s\n", $_->{"name"}, $_->{"value"}) } $or->{"headers"}->@*;
  150         300  
203 25 100       44 map { $cv .= sprintf("%s: %s\n", ucfirst($_), $or->{"commonHeaders"}->{ $_ }) if exists $or->{"commonHeaders"}->{ $_ } } @$ch;
  50         192  
204              
205 25         303 return {"ds" => $dscontents, "rfc822" => $cv};
206             }
207              
208             1;
209             __END__