Amazon SESの送信方式をSMTPからSES APIに切り替えた話(Perl + Paws)

お久しぶりです。人事・技術・経営推進本部(HTB本部)でインフラエンジニアをしている小松です。

ここ最近はコーポレートサイト周りのお仕事をしています!

さて、コーポレートサイトによくあるお問い合わせフォームのメール送信ですが、僕が担当しているサイトではAWSのSESを使って実装しています。今回はこのお問い合わせフォームのメール送信の仕組みを切り替えたことについて記事を書かせていただければと思います。

お問い合わせフォームから送信されるメール

よくあるパターンかと思いますが、お問い合わせフォームをご利用いただくと2通のメールが送信される仕組みになっています。

  • アドウェイズの担当者への連絡メール
  • お問い合わせしていただいた方へのサンキューメール

送信方法

お問い合わせフォームから送信されるメールはSMTPで送信する仕組みになっており、認証が必要です。ユーザ情報はIAMユーザを使用します。

90日以内にローテーションしないと!

以下の記事にもありますが、セキュリティガードレールの IAMアクセスキーが90日以内にローテーションされている状態 を維持する必要があります。

お問い合わせフォームで利用するIAMユーザはSMTP認証用以外にデプロイ用があり、そちらはアクセスキーの自動ローテーション機能を使っているためローテーション作業は不要です。

しかしSMTP認証用のキーはSMTPパスワード用に変換作業が必要となるため自動ローテーション機能が使えません。そのためにどうしても手作業が必要でした。

SESのAPIを使おう

そこで思い切って仕組みを大きく変えることにしました。

SMTPではなくAPIを使ってメールを送信することでSMTP認証用のIAMユーザを捨てることができます。API利用時はAPIを使用するEC2やFargateに「SESのAPIを使っていいよ」というIAMロールを付与することでメールを送信できるようになります。

お問い合わせフォームの概要

仕組みの変更にあたり、インフラとアプリの両方の修正が必要となります。お問い合わせフォームは以下の仕組みで動いています。

  • インフラはAWSで構築しており、CloudFormationで管理しています
  • アプリケーションの実行はAmazon ECSとFargateの組み合わせを利用しています。Fargateはecspressoで管理しています
  • アプリケーションはPerlで実装しておりフレームワークとしてAmon2を利用しています

アプリケーション、インフラ共にコード管理にGitLabを使っていて、リリースはgitlab-ciで行われます。CloudFormationとecspressoもgitlab-ciで実行されます(masterブランチにmergeされることで実行されます)。

これを踏まえて変更作業に取り組みました。

具体的に取り組んだこと

切り替えにあたり以下の具体的な取り組みを行いました。

  • IAMロール作成
  • ECSの変更
  • 送信プログラムの変更

IAMロール作成

お問い合わせフォームはECS(Fargate)で運用しているため、IAMロールを作成してアタッチする必要があります。

ECSの作成サンプルは以下になります。

ECSTaskExecutionRole:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Statement:
        - Effect: Allow
          Principal:
            Service: ecs-tasks.amazonaws.com
          Action: sts:AssumeRole
    # ... 他のポリシー

前述の通りAWS周りのリソースはCloudFormationで管理しているのでIAMロールもCloudFormationで作成します。

SESAPIExecutionRole:
  Type: 'AWS::IAM::Role'
  Properties:
    RoleName: !Sub "SESAccessRole"
    AssumeRolePolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Principal:
            Service: ecs-tasks.amazonaws.com
          Action: sts:AssumeRole
    Policies:
      - PolicyName: SESAccessPolicy
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - ses:SendEmail
                - ses:SendRawEmail
              Resource: "*"

作成したIAMロールをFargateタスクにアタッチするのはecspressoになります。なので、ecspressoを実行するデプロイ用IAMユーザが、先ほど作ったIAMロールを操作できるようにしてあげます。

SESAccessRolePolicy:
  Type: 'AWS::IAM::Policy'
  Properties:
    PolicyName: !Sub "allowGetRoleForSESAccessRole"
    PolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Effect: Allow
          Action: iam:GetRole
          Resource: !Sub "arn:aws:iam::アカウントID:role/SESAccessRole"
        - Effect: Allow
          Action: sts:AssumeRole
          Resource: !Sub "arn:aws:iam::アカウントID:role/SESAccessRole"
    Users:
      - ecspressoを実行するIAMユーザ名

最後に、ecspressoがこれらのIAMロールを参照できるよう、CloudFormationのOutputsで出力します。

Outputs:
  ecsTaskExecutionRoleArn:
    Value: !GetAtt ECSTaskExecutionRole.Arn
    Description: "ECS Task Execution Role ARN"
  SESAPIExecutionRoleArn:
    Value: !GetAtt SESAPIExecutionRole.Arn
    Description: "ARN of the IAM role for SES"

これでCloudFormation側の設定は完了です。

ECSの変更

続いてecspressoの設定をします。IAMロールのアタッチはtask_definitionに書きます。

{
    "family": "ファミリー名",
    "executionRoleArn": "{{ cfn_output `CloudFormationのスタック名` `ecsTaskExecutionRoleArn` }}",
    "taskRoleArn": "{{ cfn_output `CloudFormationのスタック名` `SESAPIExecutionRoleArn` }}",
    "ipcMode": "",
    "networkMode": "awsvpc",
    "pidMode": "",
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "cpu": "256",
    "memory": "512",
    "volumes": []
}

これでecspresso実行時にFargateにSESAccessPolicyがアタッチされます。

送信プログラムの変更

カスタムトランスポートの設定

お問い合わせフォームはPerlのEmail::Sender::Simpleを使っています。Email::Sender::Simpleは独自のトランスポートの使用をサポートしているので、その機能を利用しました。

カスタムトランスポートは以下のようにしています。

package Email::Sender::Transport::xSES;

use Moo;
use Paws;
use Email::Abstract;
use Try::Tiny;
use MIME::Base64 qw(encode_base64);
use namespace::autoclean;
with 'Email::Sender::Transport';

has 'region' => (
    is      => 'ro',
    default => 'us-west-2',
);

has 'ses' => (
    is      => 'lazy',
    builder => sub {
        my ($self) = @_;
        return Paws->service('SES', region => $ENV{'SES_REGION'});
    },
);

sub send_email {
    my ($self, $email, $env) = @_;

    my $email_abstract = Email::Abstract->new($email);
    my $raw_message = $email_abstract->as_string;
    my $encoded_message = encode_base64($raw_message);

    try {
        $self->ses->SendRawEmail(
            RawMessage => {
                Data => $encoded_message,
            },
        );
        return $self->success;
    }
    catch {
        die "AWS SES Error: $_";
    };
}

1;

ポイント解説

1. Pawsライブラリの使用

  • AWS SDK for PerlであるPawsを使用してSES APIを呼び出すようにしました
  • 環境変数SES_REGIONからリージョンを取得するようにしました

2. SendRawEmailの使用

  • SendRawEmailを使用することで、MIMEメッセージをそのまま送信できます
  • 日本語メールや添付ファイルにも対応しています

3. エラーハンドリング

  • Try::Tinyを使用してSES APIのエラーをキャッチします。トラブルシュート用にエラー発生時にログが残るようにしました

これでPawsによるSESのAPIを使ったメール送信のための発射台が完了です。

メールデータの生成と実際の送信

次にメールの発射台に込める弾丸(メール)と引き金を作ります。

package MyApp::Mail;

use Email::Sender::Simple qw/sendmail/;
use Email::MIME;

sub internal {
    #社内担当への通達用メール
    my ( $class, $body, $header, $param ) = @_;
    my $subject = sprintf "お問い合わせがありました";
    my $mail = $class->_build( $body, +{ %{$header}, Subject => $subject } );
    sendmail($mail);
}

sub user {
    #お問い合わせいただいたユーザ様へ送信するメール
    my ( $class, $body, $header, $to ) = @_;
    my $mail = $class->_build( $body, +{ %{$header}, To => $to } );
    sendmail($mail);
}

sub _build {
    my ( $class, $body, $conf ) = @_;
    return Email::MIME->create(
        header => [
            From    => Encode::encode( 'MIME-Header-ISO_2022_JP' => $conf->{From} ),
            To      => Encode::encode( 'MIME-Header-ISO_2022_JP' => $conf->{To} ),
            Subject => _bpl_encode( $conf->{Subject} ),
        ],
        body       => Encode::encode( 'iso-2022-jp', $body ),
        attributes => {
            content_type => 'text/plain',
            charset      => 'iso-2022-jp',
            encoding     => '7bit',
        },
    );
}

_buildメソッドでメールデータを生成し、userメソッドとinternalメソッドがxSESという発射台からメールを発射する引き金になります。

userメソッドとinternalメソッドはWEB面の送信ボタンをユーザーが押下することで呼び出されます(引き金が引かれます)。

環境変数による切り替え

ECSタスク定義で環境変数を設定することで、トランスポートを切り替えます。

{
  "environment": [
    {
      "name": "EMAIL_SENDER_TRANSPORT",
      "value": "xSES"
    },
    {
      "name": "SES_REGION", 
      "value": "us-west-2"
    }
  ]
}

Email::Sender::Simpleは EMAIL_SENDER_TRANSPORT 環境変数を自動的に読み取り、対応するトランスポートを使用します。

これにより、コードを変更することなく、環境変数の設定だけでSMTPからSESへの切り替えが可能になります。

改修してみて

キーローテーションから脱出できたのは本当によかったです。定期的な運用は頻度が減ると忘れがちなので、できるだけ0にしたいところです。

AI大活躍

CloudFormationの設定ファイルや、ecspressoの設定ファイル、各種Perlのコードの修正にあたり、WEBの生成AIにかなりお世話になりました。インターネットには大量の情報があふれており自分が望むデータを自分で取捨選択することに時間がかかります。生成AIを使うことで適切なデータをAIが提案してくれたのでめちゃくちゃ助かりました。これはもうちょっと手放せないなと感じました。今後もAIを使いこんで「人と機械の共生」をしながら仕事をしていきたいと思います!

ありがとうございました!