File Coverage

lib/Haineko/SMTPD/Relay/AmazonSES.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package Haineko::SMTPD::Relay::AmazonSES;
2 1     1   33650 use parent 'Haineko::SMTPD::Relay';
  1         357  
  1         6  
3 1     1   39 use strict;
  1         2  
  1         25  
4 1     1   5 use warnings;
  1         1  
  1         25  
5 1     1   1869 use Furl;
  0            
  0            
6             use Try::Tiny;
7             use Encode;
8             use Email::MIME;
9             use MIME::Base64;
10             use Time::Piece;
11             use Digest::SHA 'hmac_sha256_base64';
12             use Haineko::SMTPD::Response;
13              
14             use constant 'SES_ENDPOINT' => 'email.us-east-1.amazonaws.com';
15             use constant 'SES_APIVERSION' => '2010-12-01';
16              
17             sub new {
18             my $class = shift;
19             my $argvs = { @_ };
20              
21             $argvs->{'time'} ||= Time::Piece->new;
22             $argvs->{'sleep'} ||= 5;
23             $argvs->{'timeout'} ||= 30;
24             return bless $argvs, __PACKAGE__;
25             }
26              
27             sub sign {
28             my $class = shift;
29             my $value = shift; # (String) Text
30             my $skeyv = shift; # (String) Key
31              
32             my $signedtext = Digest::SHA::hmac_sha256_base64( $value, $skeyv );
33             while( length( $signedtext ) % 4 ) {
34             $signedtext .= '=';
35             }
36             return $signedtext;
37             }
38              
39             sub sendmail {
40             my $self = shift;
41              
42             if( ! $self->{'username'} || ! $self->{'password'} ) {
43             # Access Key ID(username) or Secret Key(password) is empty
44             my $r = {
45             'host' => SES_ENDPOINT,
46             'port' => 443,
47             'rcpt' => $self->{'rcpt'},
48             'code' => 400,
49             'error' => 1,
50             'mailer' => 'AmazonSES',
51             'message' => [ 'Empty Access Key ID or Secret Key' ],
52             'command' => 'POST',
53             };
54             $self->response( Haineko::SMTPD::Response->new( %$r ) );
55             return 0
56             }
57              
58             # Create email as string
59             my $headerlist = [];
60             my $emencoding = uc( $self->{'attr'}->{'charset'} || 'UTF-8' );
61             my $methodargv = {
62             'body' => Encode::encode( $emencoding, ${ $self->{'body'} } ),
63             'attributes' => $self->{'attr'},
64             };
65             utf8::encode $methodargv->{'body'} if utf8::is_utf8 $methodargv->{'body'};
66              
67             for my $e ( @{ $self->{'head'}->{'Received'} } ) {
68             # Convert email headers
69             push @$headerlist, 'Received' => $e;
70             }
71              
72             for my $e ( keys %{ $self->{'head'} } ) {
73             # Make email headers except ``MIME-Version''
74             next if $e eq 'MIME-Version';
75              
76             if( ref $self->{'head'}->{ $e } eq 'ARRAY' ) {
77             # Such as Received: header
78             for my $f ( @{ $self->{'head'}->{ $e } } ) {
79             push @$headerlist, $e => $f;
80             }
81              
82             } else {
83             push @$headerlist, $e => $self->{'head'}->{ $e };
84             }
85             }
86             $methodargv->{'header'} = $headerlist;
87              
88             my $mimeobject = Email::MIME->create( %$methodargv );
89             my $mailstring = MIME::Base64::encode_base64 $mimeobject->as_string;
90              
91             # http://docs.aws.amazon.com/ses/latest/DeveloperGuide/query-interface.html
92             my $amazonses1 = sprintf( "https://%s/", SES_ENDPOINT );
93             my $dateheader = gmtime;
94             my $datestring = $dateheader->strftime;
95             my $parameters = {
96             'Action' => 'SendRawEmail',
97             'Source' => $self->{'mail'},
98             'RawMessage.Data' => $mailstring,
99             'Destinations.member.1' => $self->{'rcpt'},
100             'Timestamp' => $dateheader->datetime.'.000Z',
101             'Version' => SES_APIVERSION,
102             };
103              
104             # AWS3 AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE,Signature=lBP67vCvGlDMBQ=dofZxg8E8SUEXAMPLE,Algorithm=HmacSHA256,SignedHeaders=Date;Host
105             my $headerkeys = [ 'AWSAccessKeyId', 'Signature', 'Algorithm' ];
106             my $reqheaders = {
107             'Date' => $datestring,
108             'Host' => SES_ENDPOINT,
109             };
110             my $identifier = {
111             'AWSAccessKeyId' => $self->{'username'},
112             'Signature' => __PACKAGE__->sign( $reqheaders->{'Date'}, $self->{'password'} ),
113             'Algorithm' => 'HmacSHA256',
114             'SignedHeaders' => 'Date',
115             };
116             my $authheader = join( ', ', map { sprintf( "%s=%s", $_, $identifier->{ $_ } ) } @$headerkeys );
117              
118              
119             $methodargv = {
120             'agent' => $self->{'ehlo'},
121             'timeout' => $self->{'timeout'},
122             'ssl_opts' => { 'SSL_verify_mode' => 0 },
123             'headers' => [
124             'date' => $datestring,
125             'host' => SES_ENDPOINT,
126             'content-type' => 'application/x-www-form-urlencoded',
127             'if-ssl-cert-subject' => sprintf( "/CN=%s", SES_ENDPOINT ),
128             'x-amzn-authorization' => sprintf( "AWS3-HTTPS %s", $authheader ),
129             ],
130             };
131             my $httpclient = Furl->new( %$methodargv );
132             my $htresponse = undef;
133             my $retryuntil = $self->{'retry'} || 0;
134             my $smtpstatus = 0;
135             my $exceptions = 0;
136             my $sendmailto = sub {
137             $htresponse = $httpclient->post( $amazonses1, undef, $parameters );
138             return 0 unless defined $htresponse;
139             return 0 unless $htresponse->is_success;
140              
141             $smtpstatus = 1;
142             return 1;
143             };
144              
145             while(1) {
146             last if $sendmailto->();
147             last if $retryuntil == 0;
148              
149             $retryuntil--;
150             sleep $self->{'sleep'};
151             }
152              
153             if( defined $htresponse ) {
154             # Check response from API
155             my $htcontents = undef;
156             my $htmimetype = $htresponse->content_type || q();
157             my $nekoparams = {
158             'code' => $htresponse->code,
159             'host' => SES_ENDPOINT,
160             'port' => 443,
161             'rcpt' => $self->{'rcpt'},
162             'error' => $htresponse->is_success ? 0 : 1,
163             'mailer' => 'AmazonSES',
164             'message' => [ $htresponse->message ],
165             'command' => 'POST',
166             };
167              
168             if( $htmimetype eq 'text/xml' ) {
169             # text/xml
170             try {
171             require XML::Simple;
172              
173             } catch {
174             # XML::Simple is not installed
175             $nekoparams->{'error'} = 1;
176             $nekoparams->{'message'} = [ 'Please install XML::Simple 2.20 or later' ];
177             $exceptions = 1;
178             };
179              
180             if( not $exceptions ) {
181             # XML::Simple is already installed
182             try {
183             # Amazon SES respond contents as a XML
184             $htcontents = XML::Simple::XMLin( $htresponse->content );
185              
186             for my $e ( keys %{ $htcontents->{'Error'} } ) {
187             # Get error messages
188             my $v = $htcontents->{'Error'}->{ $e };
189             push @{ $nekoparams->{'message'} }, sprintf( "%s=%s", $e, $v );
190             }
191              
192             } catch {
193             # It was not JSON
194             require Haineko::E;
195             my $v = $htresponse->body || q();
196              
197             $nekoparams->{'error'} = 1;
198             $nekoparams->{'message'} = [ Haineko::E->new( $v )->text ] if $v;
199             push @{ $nekoparams->{'message'} }, Haineko::E->new( $_ )->text;
200             };
201             }
202              
203             } else {
204              
205             require Haineko::E;
206             $nekoparams->{'error'} = 1;
207             $nekoparams->{'message'} = [ Haineko::E->new( $htresponse->message )->text ];
208             }
209             $self->response( Haineko::SMTPD::Response->new( %$nekoparams ) );
210             }
211              
212             return $smtpstatus;
213             }
214              
215             1;
216             __END__