以下の内容はhttps://cloud-aws-gcp.hateblo.jp/entry/2022/09/20/094526より取得しました。


SendgridのEvent WebhookログをAWSに連携する


sendgridを利用しており送信履歴をログに残したい要件がありました。

対応する機能としてSendgridではWebhook機能があり、
メールを送信や開封等のイベントを
指定のエンドポイントに送信できます。

sendgrid.kke.co.jp

サンプルの構成

実際の構成はどのようになるかと言うと、
公式のブログにサンプル構成があり、
こちらがとても参考になりました。
ただ、何点か構成を変更したい点がありました。

sendgrid.kke.co.jp

構築したい構成

上記サンプルとの違いは下記です

ログはS3に格納する

数量が膨大になりそうでしたので、より料金単価が安いS3にしました。

Lambdaを利用しない(apigatewayとs3を直接連携)

背景として、今回のケースではメールのイベントログを格納したいだけであり、
何か加工等の特別な処理をさせたい訳でなかったため、
Lambdaを利用する必要がありませんでした。
Lambda経由でS3に格納しても要件実現はできてしまうのですが、
特に、Lambdaのスケーラビリティを心配したくないことや、 ソース管理の必要があるといったデメリットを抱えてしまうため
利用しない形としました。

CDKで書きたい

サンプルで書いてみました。
apigateway周りでもう少し細かい設定を付与したいですが、
最低限のものとお考えください。

import {
  Stack,
  StackProps,
  RemovalPolicy,
  aws_apigateway as apigateway,
  aws_s3 as s3,
  aws_iam as iam,
  aws_certificatemanager as acm,
  aws_route53 as route53,
  aws_cognito as cognito,
  aws_route53_targets as targets,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

const scopeName = 'activity';

type Props = {
  hostedZoneId: string;
  domainName: string;
  domainPrefix: string;
  bucketName: string;
} & StackProps;

export class SendgridWebhookS3Stack extends Stack {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id, props);

    const { hostedZoneId, domainName, domainPrefix, bucketName } = props;

    const bucket = new s3.Bucket(this, 'Bucket', {
      bucketName: bucketName,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    const fullAccessScope = new cognito.ResourceServerScope({ scopeName: '*', scopeDescription: 'Full access' });
    const pool = new cognito.UserPool(this, 'Pool', {
      removalPolicy: RemovalPolicy.DESTROY,
    });
    const resourceServer = pool.addResourceServer('ResourceServer', {
      identifier: scopeName,
      scopes: [fullAccessScope],
    });

    pool.addDomain('CognitoDomain', {
      cognitoDomain: {
        domainPrefix: domainPrefix,
      },
    });

    pool.addClient('appClient', {
      generateSecret: true,
      oAuth: {
        flows: {
          clientCredentials: true,
        },
        scopes: [cognito.OAuthScope.resourceServer(resourceServer, fullAccessScope)],
      },
    });

    const auth = new apigateway.CognitoUserPoolsAuthorizer(this, 'Authorizer', {
      cognitoUserPools: [pool],
    });

    const restApiRole = new iam.Role(this, 'Role', {
      assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
      path: '/',
    });
    bucket.grantReadWrite(restApiRole);

    const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
      hostedZoneId: hostedZoneId,
      zoneName: domainName,
    });

    const certificate = new acm.Certificate(this, 'Certificate', {
      domainName: `*.${domainName}`,
      validation: acm.CertificateValidation.fromDns(hostedZone),
    });

    const api = new apigateway.RestApi(this, 'RestAPI', {
      domainName: {
        domainName: `${domainPrefix}.${domainName}`,
        certificate: certificate,
      },
      // disableExecuteApiEndpoint: true,
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: ['POST', 'OPTIONS'],
        statusCode: 200,
      },
      endpointConfiguration: {
        types: [apigateway.EndpointType.REGIONAL],
      },
    });
    const items = api.root.addResource('logs');
    const prefix = items.addResource('{prefix}');

    // オブジェクトをアップロードするための PUT メソッドを作成する
    prefix.addMethod(
      'POST',
      new apigateway.AwsIntegration({
        service: 's3',
        integrationHttpMethod: 'PUT',
        // アップロード先を指定する
        path: `${bucket.bucketName}/{prefix}/{fileName}`,
        options: {
          credentialsRole: restApiRole,
          passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_MATCH,
          requestParameters: {
            // メソッドリクエストのパスパラメータを統合リクエストのパスパラメータにマッピングする
            'integration.request.path.prefix': 'method.request.path.prefix',
            'integration.request.path.fileName': 'context.requestId',
          },
          integrationResponses: [
            {
              statusCode: '200',
              responseParameters: {
                'method.response.header.Timestamp': 'integration.response.header.Date',
                'method.response.header.Content-Length': 'integration.response.header.Content-Length',
                'method.response.header.Content-Type': 'integration.response.header.Content-Type',
                'method.response.header.Access-Control-Allow-Headers': "'Content-Type,Authorization'",
                'method.response.header.Access-Control-Allow-Methods': "'OPTIONS,POST,PUT'",
                'method.response.header.Access-Control-Allow-Origin': "'*'",
              },
            },
            {
              statusCode: '400',
              selectionPattern: '4\\d{2}',
              responseParameters: {
                'method.response.header.Access-Control-Allow-Headers': "'Content-Type,Authorization'",
                'method.response.header.Access-Control-Allow-Methods': "'OPTIONS,POST,PUT'",
                'method.response.header.Access-Control-Allow-Origin': "'*'",
              },
            },
            {
              statusCode: '500',
              selectionPattern: '5\\d{2}',
              responseParameters: {
                'method.response.header.Access-Control-Allow-Headers': "'Content-Type,Authorization'",
                'method.response.header.Access-Control-Allow-Methods': "'OPTIONS,POST,PUT'",
                'method.response.header.Access-Control-Allow-Origin': "'*'",
              },
            },
          ],
        },
      }),
      {
        authorizer: auth,
        authorizationScopes: [`${scopeName}/*`],
        requestParameters: {
          'method.request.path.prefix': true,
          'method.request.path.fileName': true,
        },
        methodResponses: [
          {
            statusCode: '200',
            responseParameters: {
              'method.response.header.Timestamp': true,
              'method.response.header.Content-Length': true,
              'method.response.header.Content-Type': true,
              'method.response.header.Access-Control-Allow-Headers': true,
              'method.response.header.Access-Control-Allow-Methods': true,
              'method.response.header.Access-Control-Allow-Origin': true,
            },
          },
          {
            statusCode: '400',
            responseParameters: {
              'method.response.header.Access-Control-Allow-Headers': true,
              'method.response.header.Access-Control-Allow-Methods': true,
              'method.response.header.Access-Control-Allow-Origin': true,
            },
          },
          {
            statusCode: '500',
            responseParameters: {
              'method.response.header.Access-Control-Allow-Headers': true,
              'method.response.header.Access-Control-Allow-Methods': true,
              'method.response.header.Access-Control-Allow-Origin': true,
            },
          },
        ],
      }
    );
    new route53.ARecord(this, 'ARecod', {
      zone: hostedZone,
      recordName: `${domainPrefix}.${hostedZone.zoneName}`,
      target: route53.RecordTarget.fromAlias(new targets.ApiGateway(api)),
    });
  }
}

結果確認

作成されたcognitoからclientIdとsecretを取得し、
下記の要領でリクエスト実行します。
<>で囲んだ部分はcdkで指定した値に読み替えてください。

DOMAIN=https://<domainPrefix>.auth.ap-northeast-1.amazoncognito.com
APP_CLIENT_ID=xxxxxx
APP_CLIENT_SECRET=xxxxx
curl -s -X POST ${DOMAIN}/oauth2/token \
-H "Authorization: Basic $(echo -n ${APP_CLIENT_ID}:${APP_CLIENT_SECRET} | base64 )" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${APP_CLIENT_ID}"


curl -X POST 'https://<domainPrefix>.<domainName>/logs/<prefix>/' \
--header "Authorization: Bearer xxxxxxxxxxx" \
--data '{"message":"Hello"}'

S3にログが格納されていることを確認できました。
S3内ではprefixで指定した文字列の配下にログが格納されます。
下記はprefixにsampleを指定した場合の例です。

おまけで、SendgridではSubuserという機能があり、
論理単位でログの出力を複数設定することができます。
そのため、特定用途のメールのみprefixを分けて
ログ出力するといったことも可能で下。 sendgrid.kke.co.jp




以上の内容はhttps://cloud-aws-gcp.hateblo.jp/entry/2022/09/20/094526より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14