The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Haineko::SMTPD::Relay::AmazonSES;
use parent 'Haineko::SMTPD::Relay';
use strict;
use warnings;
use Furl;
use Try::Tiny;
use Encode;
use Email::MIME;
use MIME::Base64;
use Time::Piece;
use Digest::SHA 'hmac_sha256_base64';
use Haineko::SMTPD::Response;

use constant 'SES_ENDPOINT' => 'email.us-east-1.amazonaws.com';
use constant 'SES_APIVERSION' => '2010-12-01';

sub new {
    my $class = shift;
    my $argvs = { @_ };

    $argvs->{'time'}    ||= Time::Piece->new;
    $argvs->{'sleep'}   ||= 5;
    $argvs->{'timeout'} ||= 30;
    return bless $argvs, __PACKAGE__;
}

sub sign {
    my $class = shift;
    my $value = shift;  # (String) Text
    my $skeyv = shift;  # (String) Key

    my $signedtext = Digest::SHA::hmac_sha256_base64( $value, $skeyv );
    while( length( $signedtext ) % 4 ) {
        $signedtext .= '=';
    }
    return $signedtext;
}

sub sendmail {
    my $self = shift;

    if( ! $self->{'username'} || ! $self->{'password'} ) {
        # Access Key ID(username) or Secret Key(password) is empty
        my $r = {
            'host'    => SES_ENDPOINT,
            'port'    => 443,
            'rcpt'    => $self->{'rcpt'},
            'code'    => 400,
            'error'   => 1,
            'mailer'  => 'AmazonSES',
            'message' => [ 'Empty Access Key ID or Secret Key' ],
            'command' => 'POST',
        };
        $self->response( Haineko::SMTPD::Response->new( %$r ) );
        return 0
    }

    # Create email as string
    my $headerlist = [];
    my $emencoding = uc( $self->{'attr'}->{'charset'} || 'UTF-8' );
    my $methodargv = {
        'body' => Encode::encode( $emencoding, ${ $self->{'body'} } ),
        'attributes' => $self->{'attr'},
    };
    utf8::encode $methodargv->{'body'} if utf8::is_utf8 $methodargv->{'body'};

    for my $e ( @{ $self->{'head'}->{'Received'} } ) {
        # Convert email headers
        push @$headerlist, 'Received' => $e;
    }

    for my $e ( keys %{ $self->{'head'} } ) {
        # Make email headers except ``MIME-Version''
        next if $e eq 'MIME-Version';

        if( ref $self->{'head'}->{ $e } eq 'ARRAY' ) {
            # Such as Received: header
            for my $f ( @{ $self->{'head'}->{ $e } } ) {
                push @$headerlist, $e => $f;
            }

        } else { 
            push @$headerlist, $e => $self->{'head'}->{ $e };
        }
    }
    $methodargv->{'header'} = $headerlist;

    my $mimeobject = Email::MIME->create( %$methodargv );
    my $mailstring = MIME::Base64::encode_base64 $mimeobject->as_string;

    # http://docs.aws.amazon.com/ses/latest/DeveloperGuide/query-interface.html
    my $amazonses1 = sprintf( "https://%s/", SES_ENDPOINT );
    my $dateheader = gmtime;
    my $datestring = $dateheader->strftime;
    my $parameters = {
        'Action' => 'SendRawEmail',
        'Source' => $self->{'mail'},
        'RawMessage.Data' => $mailstring,
        'Destinations.member.1' => $self->{'rcpt'},
        'Timestamp' => $dateheader->datetime.'.000Z',
        'Version' => SES_APIVERSION,
    };

    # AWS3 AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE,Signature=lBP67vCvGlDMBQ=dofZxg8E8SUEXAMPLE,Algorithm=HmacSHA256,SignedHeaders=Date;Host
    my $headerkeys = [ 'AWSAccessKeyId', 'Signature', 'Algorithm' ];
    my $reqheaders = {
        'Date' => $datestring,
        'Host' => SES_ENDPOINT,
    };
    my $identifier = {
        'AWSAccessKeyId' => $self->{'username'},
        'Signature' => __PACKAGE__->sign( $reqheaders->{'Date'}, $self->{'password'} ),
        'Algorithm' => 'HmacSHA256',
        'SignedHeaders' => 'Date',
    };
    my $authheader = join( ', ', map { sprintf( "%s=%s", $_, $identifier->{ $_ } ) } @$headerkeys );


    $methodargv = { 
        'agent' => $self->{'ehlo'},
        'timeout' => $self->{'timeout'},
        'ssl_opts' => { 'SSL_verify_mode' => 0 },
        'headers' => [
            'date' => $datestring,
            'host' => SES_ENDPOINT,
            'content-type' => 'application/x-www-form-urlencoded',
            'if-ssl-cert-subject' => sprintf( "/CN=%s", SES_ENDPOINT ),
            'x-amzn-authorization' => sprintf( "AWS3-HTTPS %s", $authheader ),
        ],
    };
    my $httpclient = Furl->new( %$methodargv );
    my $htresponse = undef;
    my $retryuntil = $self->{'retry'} || 0;
    my $smtpstatus = 0;
    my $exceptions = 0;
    my $sendmailto = sub {
        $htresponse = $httpclient->post( $amazonses1, undef, $parameters );
        return 0 unless defined $htresponse;
        return 0 unless $htresponse->is_success;

        $smtpstatus = 1;
        return 1;
    };

    while(1) {
        last if $sendmailto->();
        last if $retryuntil == 0;

        $retryuntil--;
        sleep $self->{'sleep'};
    }

    if( defined $htresponse ) {
        # Check response from API
        my $htcontents = undef;
        my $htmimetype = $htresponse->content_type || q();
        my $nekoparams = { 
            'code'    => $htresponse->code,
            'host'    => SES_ENDPOINT,
            'port'    => 443,
            'rcpt'    => $self->{'rcpt'},
            'error'   => $htresponse->is_success ? 0 : 1,
            'mailer'  => 'AmazonSES',
            'message' => [ $htresponse->message ],
            'command' => 'POST',
        };

        if( $htmimetype eq 'text/xml' ) {
            # text/xml
            try { 
                require XML::Simple;

            } catch {
                # XML::Simple is not installed
                $nekoparams->{'error'} = 1;
                $nekoparams->{'message'} = [ 'Please install XML::Simple 2.20 or later' ];
                $exceptions = 1;
            };

            if( not $exceptions ) {
                # XML::Simple is already installed
                try {
                    # Amazon SES respond contents as a XML
                    $htcontents = XML::Simple::XMLin( $htresponse->content );

                    for my $e ( keys %{ $htcontents->{'Error'} } ) {
                        # Get error messages
                        my $v = $htcontents->{'Error'}->{ $e };
                        push @{ $nekoparams->{'message'} }, sprintf( "%s=%s", $e, $v );
                    }

                } catch {
                    # It was not JSON
                    require Haineko::E;
                    my $v = $htresponse->body || q();

                    $nekoparams->{'error'} = 1;
                    $nekoparams->{'message'} = [ Haineko::E->new( $v )->text ] if $v;
                    push @{ $nekoparams->{'message'} }, Haineko::E->new( $_ )->text;
                };
            }

        } else {

            require Haineko::E;
            $nekoparams->{'error'} = 1;
            $nekoparams->{'message'} = [ Haineko::E->new( $htresponse->message )->text ];
        }
        $self->response( Haineko::SMTPD::Response->new( %$nekoparams ) );
    }

    return $smtpstatus;
}

1;
__END__

=encoding utf8

=head1 NAME

Haineko::SMTPD::Relay::AmazonSES - Amazon SES API class for sending email

=head1 DESCRIPTION

Send an email to a recipient via Amazon SES using API.

=head1 SYNOPSIS

    use Haineko::SMTPD::Relay::AmazonSES;
    my $h = { 'Subject' => 'Test', 'To' => 'neko@example.org' };
    my $v = { 
        'username' => 'Access Key ID', 'password' => 'Secret Key',
        'ehlo' => 'UserAgent name for Furl',
        'mail' => 'kijitora@example.jp', 'rcpt' => 'neko@example.org',
        'head' => $h, 'body' => 'Email message',
    };
    my $e = Haineko::SMTPD::Relay::AmazonSES->new( %$v );
    my $s = $e->sendmail;

    print $s;                   # 0 = Failed to send, 1 = Successfully sent
    print $e->response->error;  # 0 = No error, 1 = Error
    print $e->response->dsn;    # always returns undef
    print $e->response->code;   # HTTP response code from AmazonSES API

    warn Data::Dumper::Dumper $e->response;
    $VAR1 = bless( {
             'dsn' => undef,
             'error' => 0,
             'code' => '200',
             'message' => [ 'OK' ],
             'command' => 'POST'
            }, 'Haineko::SMTPD::Response' );

=head1 CLASS METHODS

=head2 C<B<new( I<%arguments> )>>

C<new()> is a constructor of Haineko::SMTPD::Relay::AmazonSES

    my $e = Haineko::SMTPD::Relay::AmazonSES->new( 
            'username' => 'username',       # API Access Key ID for AmazonSES
            'password' => 'password',       # API Secret Key for AmazonSES
            'timeout' => 60,                # Timeout for Furl
            'attr' => {
                'content_type' => 'text/plain'
            },
            'head' => {                     # Email header
                'Subject' => 'Test',
                'To' => 'neko@example.org',
            },
            'body' => 'Email message',      # Email body
            'mail' => 'kijitora@example.jp',# Envelope sender
            'rcpt' => 'cat@example.org',    # Envelope recipient
    );

=head1 INSTANCE METHODS

=head2 C<B<sendmail>>

C<sendmail()> will send email to the specified recipient via specified host.

    my $e = Haineko::SMTPD::Relay::AmazonSES->new( %argvs );
    print $e->sendmail;         # 0 = Failed to send, 1 = Successfully sent
    print Dumper $e->response;  # Dumps Haineko::SMTPD::Response object

=head1 SEE ALSO

http://docs.aws.amazon.com/ses/latest/DeveloperGuide/query-interface.html

=head1 REPOSITORY

https://github.com/azumakuniyuki/Haineko

=head1 AUTHOR

azumakuniyuki E<lt>perl.org [at] azumakuniyuki.orgE<gt>

=head1 LICENSE

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut