AWS システム開発関連

[AWS Lambda] 給与明細PDFの処理を、手動アップロードからメール受信まで自動化した

Amazon SESで受信した給与明細メールをS3へ保存し、2つのLambdaで添付抽出とPDF復号を行うAWS構成図

以前、給与明細のパスワード付きPDFをS3へ手動でアップロードすると、Lambdaが自動でパスワードを解除する仕組みを作りました。

その時点では「メールから添付ファイルを保存してS3へ置く」ところが手作業でしたが、構成をかなり見直しました。
現在は、給与明細メールの受信、添付PDFの抽出、パスワード解除までをAWS上で完結する構成になっています。

単に機能を足しただけではなく、qpdfを入れたコンテナ方式からPythonライブラリを含めたZIP方式へ変更し、AWSリソースもTerraformで管理する方針へ転換しました。
今回は、前回の記事から何を変えたのか、その理由と実装内容をまとめます。

⚠ 注意事項

設定内容やプログラムの内容は、用途・環境に応じて適切なものが変わります。

各種設定値や環境情報についてよく理解を深め、壊れてもよい環境で十分に検証してください。

なるべく正確に書くよう心掛けていますが、本投稿内容を実施される際には自己責任の下でお願いいたします。

前回の構成と、残っていた手作業

最初に作った構成は、パスワード付きPDFをS3のincoming/へ手動でアップロードし、そのイベントをきっかけにLambdaを起動するものでした。
LambdaコンテナにはPythonとqpdfを入れ、復号後のPDFをdecrypted/へ保存していました。

給与明細メールを受信
        ↓ 手作業
添付PDFを保存
        ↓ 手作業
S3 incoming/へアップロード
        ↓ 自動
Lambda + qpdfで復号
        ↓
S3 decrypted/へ保存

Lambdaの学習という目的は達成できましたが、毎月やる作業として考えると、メールを開いて添付を保存し、S3へアップロードする部分が残っています。
前回の記事でも「いずれメール受信から自動化したい」と書いていたので、そこへ進むことにしました。

新しく実装した現在の構成

現在は、ローカルのクライアントで給与明細を受信したらフィルター機能で、SESに転送。
給与明細メールをSES上で受信して生メールをS3へ保存し、2つのLambdaを順番に動かします。

メール送信元
    ↓ SMTP
Amazon SES
    ↓ 生メールを保存
S3 kyuyo-mail-original/
    ↓ ObjectCreated
添付抽出Lambda
    ↓ MIMEメールからPDFだけを抽出
S3 incoming/[メールID]/[添付ファイル名]
    ↓ ObjectCreated(.pdf)
PDF復号Lambda
    ↓ pypdfで復号
S3 decrypted/[添付ファイル名]

Route 53にはSES受信用のMXレコードを設定します。
Lambdaの実行ログはCloudWatch Logsへ出力し、IAMロールではS3の対象プレフィックスに必要な読み書き権限だけを与えています。

変更1:メールの受信をAmazon SESへ任せた

手動作業をなくす入口として、給与明細専用の宛先をAmazon SESで受信する構成にしました。
SESの受信ルールがメールを受け取ると、RFC 5322/MIME形式の生メールをS3のkyuyo-mail-original/へ保存します。

SESの設定だけでなく、対象ドメインのMXレコード、DKIM用CNAME、カスタムMAIL FROM用のレコードもRoute 53で管理しています。
受信ルールセットはTerraformから有効化するため、別のルールセットを利用している環境へ適用する場合は注意が必要です。

受信したメールをそのまま復号Lambdaへ渡すのではなく、いったん生メールをS3に残します。
処理に失敗したときに元データを確認しやすく、添付抽出処理だけをやり直すこともできます。

変更2:添付PDFを取り出すLambdaを追加した

pdf-attachment-extractorというLambdaを追加しました。
S3に保存された生メールを取得し、Python標準ライブラリのemail.parser.BytesParserでMIME構造を解析します。
添付のContent-Typeがapplication/pdf、またはファイル名が.pdfで終わるものだけを取り出します。

抽出したPDFは、次の形でincoming/へ保存します。

incoming/<SESのメールオブジェクト名>/<添付ファイル名>

メール単位の階層を挟むことで、異なるメールに同じ名前の添付があっても、復号前のファイルがすぐに衝突しないようにしています。
同じメール内に同名ファイルが複数ある場合は、2個目以降へ-2-3と番号を付けます。

添付ファイル名にはパス区切り文字や制御文字が含まれる可能性があるので、S3キーを組み立てる前に置換します。
Codexとのやり取りでは、正常系だけでなく、../payslip.pdfのような名前や同名添付もテストする形へ詰めていきました。

変更3:qpdfコンテナからpypdf入りZIPへ

もう一つの大きな方針転換が、PDF復号Lambdaの配布方式です。
前回はqpdfをインストールしたDockerイメージをECRへ置いていましたが、現在はpypdf[crypto]を同梱したZIPでデプロイします。

項目以前現在
復号処理qpdfを外部コマンドとして実行Pythonからpypdfを呼び出す
Lambdaの形式コンテナイメージZIP
配布先Amazon ECRLambdaへZIPを登録
ビルドDockerでイメージ作成PowerShellで依存関係を含むZIPを作成

今回の処理はPDFを復号するだけなので、Pythonライブラリで完結させると構成を小さくできます。
ECRにイメージを置く必要もなくなりました。PdfReaderでPDFを開き、暗号化されていれば設定したパスワードで復号し、PdfWriterで新しいPDFへ書き出します。

reader = PdfReader(input_path)
if reader.is_encrypted and reader.decrypt(PDF_PASSWORD) == 0:
    raise RuntimeError("PDF password is incorrect")

writer = PdfWriter()
writer.clone_document_from_reader(reader)

パスワードは引き続きPDF_PASSWORDから読み取ります。
Terraformでは変数をsensitiveにしていますが、値はTerraform stateに保存されるので、state自体の管理も必要です。

変更4:AWSの設定をTerraformへ移した

最初はIAMポリシーやS3通知のJSONを置いていましたが、構成が増えると手作業での再現が難しくなります。
そこで、次のリソースをTerraformで管理するようにしました。

  • S3バケットとPublic Access Block
  • SESからS3へ保存するためのバケットポリシー
  • 2つのLambda関数と共通IAMロール
  • CloudWatch Logsのロググループと90日間の保持設定
  • S3から各Lambdaを呼び出す権限とイベント通知
  • SESのドメインID、設定セット、受信ルールセット
  • Route 53のMX・DKIM・MAIL FROM用レコード

S3バケットやLambda、ロググループなどにはprevent_destroyを設定しました。
設定ミスで重要なデータや本番リソースを消しにくくするためです。
また、対応するリソースには共通タグを付け、どのプロジェクトが管理しているか分かるようにしています。

手順をコードに寄せたことで、変更内容をGitの差分として確認できるようになりました。
Codexへ相談するときも、「今のAWSコンソールがどうなっているか」ではなく、Terraformの定義を一緒に確認できるのが便利でした。

変更5:ローカル確認とZIP作成をやりやすくした

クラウドへデプロイしてから問題を見つけると確認に時間がかかるため、local_run.pyを追加しました。
S3クライアントだけをローカルファイル操作に差し替え、実際のLambdaハンドラーを呼び出します。
PyCharmからブレークポイントを置いて確認でき、AWS認証情報やS3バケットは不要です。

python local_run.py samples/input.pdf samples/output.pdf

パスワードを環境変数で渡さなかった場合は、ターミナルで対話入力します。
出力先がすでに存在する場合は上書きせず、作業用の一時ディレクトリも終了時に削除します。

デプロイ用にはscripts/build_lambda_zips.ps1を用意しました。
pypdfと依存パッケージをLinux/Python 3.13向けに取得して復号LambdaのZIPを作り、標準ライブラリだけで動く添付抽出Lambdaはコードだけを別のZIPにします。
Pythonコマンドの存在、pipとZIP作成の終了コードも確認し、失敗時にそのまま進まないようにしています。

リポジトリの構成も整理した

Lambdaが2つになったため、ソースを役割ごとのディレクトリへ移しました。

lambdas/
├─ email_extractor/
│  └─ email_extractor.py
└─ pdf_unlock/
   └─ lambda_function.py

scripts/
└─ build_lambda_zips.ps1

terraform/
├─ main.tf
├─ variables.tf
├─ outputs.tf
└─ terraform.tfvars.example

tests/
└─ test_lambda_function.py

deploy/には生成したZIPを置きますが、成果物そのものはGit管理から外し、空ディレクトリを維持するための.gitkeepだけをコミットしています。
READMEにはローカル実行とZIP作成、Terraform適用時の注意をまとめました。

Codexを利用しながら進めて良かった点

今回の改修では、Codexに一度に完成形を作らせるというより、差分を小さく確認しながら進めました。
コンテナを外してZIPへ変更する、ビルドをスクリプト化する、Lambdaのソースを整理する、SESとRoute 53をTerraformへ追加する、といった単位でコミットを分けています。

途中で手順と実装がずれた箇所はREADMEも直し、ZIP作成スクリプトはエラー処理を追加しました。
最後に、AWS公式のサービスアイコンを使った構成図もREADMEへ追加しています。
コードだけでなく、後から自分が見ても再構築できる状態へ近づけられたと思います。

まだ残っていること

メール受信からPDF復号まではつながりましたが、最終目標だった家計簿アプリへの登録はこれからです。
復号後のPDFから支給額や控除額をどのように抽出するか、内容を確認してから登録するか、二重登録をどう防ぐかを考える必要があります。

また、現在のdecrypted/はファイル名だけで保存するため、別メールの同名添付は後の処理で上書きされます。
出力先にもメールIDや年月を含めること、環境変数で持っているPDFパスワードの管理方法、S3に残す生メールと復号済みPDFのライフサイクル設定も次の改善候補です。

ソースコード

今回の改修後のコード、Terraform定義、ZIP作成スクリプト、構成図はGitHubで公開しています。

GitHub:k636174/pdf-decryppt-lambda(20260913版)

-AWS, システム開発関連
-, ,