Featured image of post メールが届いたら Google Home で音声で通知する

メールが届いたら Google Home で音声で通知する

以前、「LINE に送ったメッセージを Google Home に読み上げさせる」という記事を書きました。 その時に作ったものに家にあるラズパイで Cloud PubSub を subscribe してメッセージが届いたらその内容を Text-to-Speach で音声化して Google Home で再生する仕組みが存在します。

やりたいこと

今回はメール受信をトリガーにして Google Home に喋らせたいと思いました。 塾側のシステムで息子が塾に行った時と帰る時にメールを送ってくれるようにはなっているのですが、家に着くであろう時刻を知るのにわざわざメールを見なくてもよくしたかったのです。

構成を考える

メール受信をトリガーにしてなにかを実行するのに使えるサービスには何があるだろうかと考えたのですが Amazon SES しか思いつきませんでした。

Amazon SES には受信機能があり、受信をトリガーに Lambda を実行することができます。 この Lambda でメッセージを前回作った Cloud PubSub Topic に送ることでやりたいことはできそうです。


構成図

構成図


マルチクラウドになって面倒ですが、今は AWS の IAM Role でも private key 無しで Google Cloud の ServiceAccount を使えるようになっているのでこれも活用してみます。

Amazon SES でメール受信できるようにする

SES での受信は東京リージョンでは提供されていないためバージニアリージョン (us-east-1) を使用することにしました。

受信したメールは S3 に保存した後、Lambda を実行することにします。 受信のためのルールを設定するためには依存する S3 Bucket と Lambda が先に存在する必要があったので次の順番でリソースを作成します。

  • S3 Bucket 作成
  • Lambda Function 作成
  • SES 受信設定

S3 に保存しなくてもメールのヘッダー情報は Lambda に渡されます。私の今回の要件でも Subject さえ受信できれば良かったので、Lambda では S3 の object にはアクセスしていませんが、Gmail からの転送先としてメールアドレスを登録する際にそのアドレス宛のメールが受信できることを確認できる必要があり、そのためには届いたメールの本文にある URL にアクセスする必要があります。 このために S3 からメールを取り出して対処しました。

SES からの Lambda 起動時に渡される event データ Header の MIME Encode は decode されたものが渡されるので楽ちんです。(sample)

次から terraform の設定例を書いてみます。

変数定義

依存関係の問題でリソース参照ができないところがあるので変数として定義しておく

locals {
  receipt_rule_set_name                 = "ルールセット名"
  receipt_rule_name                     = "ルール名"
  function_name                         = "Lambda function 名"
  lambda_role_name                      = "Lambda 用 IAM Role 名"
  pubsub_topic_name                     = "Cloud PubSub Topic 名"
  google_project_id                     = "Google Project ID"
  google_project_number                 = "Google Project Number"
  email_address                         = "受信用メールアドレス"
  google_workload_identity_pool_name    = "aws-pool"
  google_workload_identity_provder_name = "my-aws-provider"
}

provider 設定

東京リージョンのリソースをすでに管理している terraform リポジトリに追加するのでバージニア用のエイリアスを定義します。

provider "aws" {
  alias  = "virginia"
  region = "us-east-1"
}

S3 Bucket 作成

S3 Bucket の作成

resource "aws_s3_bucket" "mail_received" {
  provider = aws.virginia
  bucket   = "バケット名"
}

SES サービスが Object を保存することができるように bucket policy を設定する

data "aws_iam_policy_document" "ses_writable" {
  statement {
    sid       = "AllowSESPuts"
    effect    = "Allow"
    actions   = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.mail_received.arn}/*"]
    principals {
      type        = "Service"
      identifiers = ["ses.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceAccount"
      values   = [data.aws_caller_identity.current.account_id]
    }
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      # 依存関係の問題で aws_ses_receipt_rule の arn を使えない
      values   = ["arn:aws:ses:us-east-1:${data.aws_caller_identity.current.account_id}:receipt-rule-set/${receipt_rule_set_name}:receipt-rule/${local.receipt_rule_name}"]
    }
  }
}

resource "aws_s3_bucket_policy" "mail_received" {
  provider = aws.virginia
  bucket   = aws_s3_bucket.mail_received.id
  policy   = data.aws_iam_policy_document.ses_writable.json
}

メールをいつまでも残しておく必要はないので lifecycle 設定で削除した方が良いです。

Lambda Function 作成

Go言語で実装しました。Container でデプロイしようかとも考えたのですが無駄に image が大きくなるので zip ファイルでデプロイすることにしました。

Go の場合、次の package を使うと非常に簡単に実装できました。

zip ファイルの作成

main というファイル名で実行ファイルを build して main.zip という zip ファイルとして保存します。

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
zip main.zip main

zip ファイルの hash 値を得るために local_file の data resource で指定します。

data "local_file" "lambda_zip" {
  filename = "${path.module}/main.zip"
}

LogGroup 作成

保持期間を指定したいので Log Group も terraform で作成する。

resource "aws_cloudwatch_log_group" "lambda" {
  provider          = aws.virginia
  name              = "/aws/lambda/${local.function_name}"
  retention_in_days = 14
}

Lambda 用 IAM Role 作成

Lambda Function の実行用 IAM Role 作成

data "aws_iam_policy_document" "lambda_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "lambda" {
  name               = local.lambda_role_name
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}

# CloudWatch Logs へのログ出力と lambda 関連の許可
data "aws_iam_policy_document" "lambda" {
  statement {
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]
    effect    = "Allow"
    resources = ["*"]
  }
  statement {
    actions   = ["lambda:*"]
    effect    = "Allow"
    resources = ["*"]
  }
}

resource "aws_iam_policy" "lambda" {
  name   = "{Lambda 用 IAM Policy 名}"
  policy = data.aws_iam_policy_document.lambda.json
}

# Policy を Role に紐付ける
resource "aws_iam_role_policy_attachment" "ses_to_cloud_pubsub" {
  role       = aws_iam_role.lambda.name
  policy_arn = aws_iam_policy.lambda.arn
}

Google Cloud で identity provider 設定

Lambda 関数の環境変数で設定するために Google の Workload Identity Federation 設定を行う

(aws 用の terraform と一緒に管理するかどうかわからないけど一緒になってる前提で変数を使っている)

resource "google_iam_workload_identity_pool" "aws" {
  workload_identity_pool_id = local.google_workload_identity_pool_name
  display_name              = local.google_workload_identity_pool_name
}

resource "google_iam_workload_identity_pool_provider" "aws_main" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.aws.workload_identity_pool_id
  workload_identity_pool_provider_id = local.google_workload_identity_provder_name
  display_name                       = local.google_workload_identity_provder_name
  attribute_mapping = {
    "google.subject"     = "assertion.arn"
    "attribute.aws_role" = "assertion.arn.extract('assumed-role/{role}/')"
  }
  aws {
    account_id = data.aws_caller_identity.current.account_id
  }
}

Google Cloud で Service Account の設定

ServiceAccount の作成

resource "google_service_account" "aws_ses_to_pubsub" {
  project      = local.project_id
  account_id   = "ses-to-pubsub"
  display_name = "AWS SES to Cloud PubSub Lambda"
}

ServiceAccount に対して Topic への Publish を許可する

resource "google_pubsub_topic_iam_member" "aws_ses_to_pubsub" {
  project = google_pubsub_topic.topic.project
  topic   = google_pubsub_topic.topic.name
  role    = "roles/pubsub.publisher"
  member  = "serviceAccount:${google_service_account.aws_ses_to_pubsub.email}"
}

ServiceAccount を AWS の特定の IAM Role から利用可能にする

resource "google_service_account_iam_member" "aws_ses_to_pubsub" {
  service_account_id = google_service_account.aws_ses_to_pubsub.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.aws.name}/attribute.aws_role/${local.lambda_role_name}"
}

Lambda Function 作成

Workload Identity 連携させる場合、GOOGLE_APPLICATION_CREDENTIALS で指定するファイルに Google から提供される JSON を保存しておく必要がある。この JSON に秘密鍵は入っていない。

この JSON は Google Cloud の Console で IAM & AdminWorkload Identity Federation にアクセスし、AWS 用の pool を選択すると右側に PROVIDERSCONNECTED SERVICE ACCOUNTS タブがあり、CONNECTED SERVICE ACCOUNTS で今回作成したアカウントの右 (Client Library Config 列) にある DOWNLOAD リンクから JSON ファイルをダウンロードする。

今回の Go のコードでは init() の中で GOOGLE_APPLICATION_CREDENTIALS_VALUE で指定された値を GOOGLE_APPLICATION_CREDENTIALS ファイルに保存するようにした。

resource "aws_lambda_function" "ses_to_cloud_pubsub" {
  provider         = aws.virginia
  function_name    = local.function_name
  filename         = "${path.module}/main.zip"
  role             = aws_iam_role.lambda.arn
  handler          = "main"
  source_code_hash = data.local_file.lambda_zip.content_sha256
  runtime          = "go1.x"
  timeout          = 10

  environment {
    variables = {
      PUBSUB_TOPIC_NAME                    = local.pubsub_topic_name
      GOOGLE_PROJECT_ID                    = local.google_project_id
      GOOGLE_APPLICATION_CREDENTIALS       = "/tmp/google.json"
      GOOGLE_APPLICATION_CREDENTIALS_VALUE = <<EOT
{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/${local.google_project_number}/locations/global/workloadIdentityPools/${local.google_workload_identity_pool_name}/providers/${local.google_workload_identity_provder_name}",
  "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
  "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${local.google_service_account_email}:generateAccessToken",
  "token_url": "https://sts.googleapis.com/v1/token",
  "credential_source": {
    "environment_id": "aws1",
    "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
    "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
    "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
  }
}
EOT
    }
  }

  depends_on = [
    aws_cloudwatch_log_group.lambda,
  ]
}

今回は go build と zip ファイル化は terraform 外で実行していますが、これも terraform でやってしまうのも手です。

SES が Lambda を実行できるようにする

resource "aws_lambda_permission" "ses_to_cloud_pubsub" {
  provider       = aws.virginia
  action         = "lambda:InvokeFunction"
  function_name  = local.function_name
  principal      = "ses.amazonaws.com"
  source_account = data.aws_caller_identity.current.account_id
  source_arn     = ["arn:aws:ses:us-east-1:${data.aws_caller_identity.current.account_id}:receipt-rule-set/${receipt_rule_set_name}:receipt-rule/${local.receipt_rule_name}"]
}

SES の受信設定

過去にすでに設定したことがあったのでもうドメインは使えるようになっていましたが、初回であればドメインとかメールアドレスの所有確認が必要かも。

resource "aws_ses_receipt_rule_set" "rule_set" {
  rule_set_name = local.receipt_rule_set_name
}
resource "aws_ses_receipt_rule" "rule" {
  provider      = aws.virginia
  name          = local.receipt_rule_name
  rule_set_name = local.receipt_rule_set_name
  recipients    = [local.email_address]
  enabled       = true
  scan_enabled  = true
  tls_policy    = "Require"

  # まず S3 にメッセージを保存
  s3_action {
    position          = 1
    bucket_name       = aws_s3_bucket.mail_received.id
    object_key_prefix = ""
  }

  # Lambda 関数を実行
  lambda_action {
    function_arn    = aws_lambda_function.ses_to_cloud_pubsub.arn
    invocation_type = "Event"
    position        = 2
  }

  depends_on = [
    aws_s3_bucket.mail_received,
    aws_s3_bucket_policy.mail_received,
    aws_lambda_permission.ses_to_cloud_pubsub,
  ]
}

以上、これでメールが届くと音声で教えてくれるようになりました。

めでたしめでたし。

Built with Hugo
テーマ StackJimmy によって設計されています。