ota2000
6 min read

保育園の通知メールを LINE に自動転送する

息子の通う保育園では専用の連絡システムを使っていて、登園・降園の通知や日々の連絡が指定したメールアドレスに届く。便利なんだけど、メールだと気づかないことがある。妻と共有したいこともあり、LINE グループに転送する仕組みを作った。

アーキテクチャ

flowchart TD
    A["保育園連絡システム"] -->|メール送信| B["Gmail"]
    B -->|Watch Push 通知| C["Cloud Pub/Sub"]
    C -->|トリガー| D["Cloud Run functions"]
    D -->|Gmail API で本文取得| B
    D -->|ログイン & スクレイピング| F["Web ポータル"]
    D -->|画像・PDF アップロード| G["Cloud Storage"]
    D -->|メッセージ送信| E["LINE グループ"]
    G -.->|署名付きURL| E

Gmail API の Push 通知を使う。メールが届くと Gmail が Pub/Sub にメッセージを発行し、Cloud Run functions が起動してメールを取得、LINE に転送する。ポーリングではなくイベント駆動なのでほぼリアルタイム。

保育園からのメールには2種類ある。

  • URL 付きメール — メール本文には「ログインURL」だけ記載されていて、実際の連絡内容(テキスト、写真、PDF)は Web ポータルにログインしないと見られない
  • URL なしメール — 登園・降園通知など、メール本文に内容が直接書かれているもの

URL 付きメールの場合、Cloud Run functions が Web ポータルに自動ログインしてメッセージを取得し、画像は LINE の image メッセージ、PDF は Flex Message のボタンとして送信する。

アプリケーション構成

main.py — エントリポイント

handle_gmail_notification (Pub/Sub トリガー)

  1. Gmail から Pub/Sub 経由で「メールが届いた」通知を受け取る
  2. 保育園からの未読メールを Gmail API で検索
  3. メール本文にポータルの URL が含まれるか判定
  4. URL あり → ポータルへログインしてコンテンツ取得 → 画像・PDF は GCS へアップロード → LINE に送信
  5. URL なし → メール本文をそのまま LINE に送信
  6. メールを既読にする

ポータルへのアクセスが失敗した場合はフォールバックとしてメール本文をそのまま送信する。

renew_watch (HTTP トリガー、Cloud Scheduler から6日ごと呼び出し)

  • Gmail の Push 通知登録(watch())を更新する(有効期限7日のため)

Web ポータルからのコンテンツ取得

requests.Session でログインしてセッション Cookie を維持し、メッセージ詳細ページを BeautifulSoup でパースする。タイトル・本文・添付ファイル(画像や PDF)のリンクを抽出し、添付はセッションを使ってダウンロードする。

セッション切れやログイン失敗はレスポンス内容から検出し、例外を投げてフォールバックに回す。

@dataclasses.dataclass
class Message:
    title: str
    body: str
    attachments: list[dict]  # [{"filename": str, "url": str}]

gmail_client.py — Gmail API ラッパー

  • OAuth2 refresh token で認証
  • Google が refresh token をローテーションした場合、新しいトークンを自動で Secret Manager に書き戻す
  • get_unread_messages() — 指定条件の未読メール取得
  • mark_as_read() / watch() — 既読化・Push 通知登録

line_client.py — LINE Messaging API

LINE Push API で3種類のメッセージを送信する。

  • テキスト — タイトル + 本文 + 元URL
  • 画像image メッセージタイプ(トーク画面には Pillow で生成した 240px のサムネイル、タップでオリジナル画像を表示)
  • PDF — Flex Message のボタン UI(タップで GCS 署名付き URL を開く)

1回の push リクエストで最大5件のメッセージを送信でき、超える場合は自動で分割する。

インフラ構成

Terraform で管理している。主なリソースは以下。

  • Cloud Run functions × 2(通知処理 + watch 更新)
  • Cloud Pub/Sub — Gmail からの Push 通知受け口
  • Cloud Scheduler — 6日ごとの watch 再登録
  • Secret Manager — OAuth トークン、LINE トークン、ポータルのログイン情報
  • Cloud Storage — ポータルから取得した画像・PDF の保存(30日で自動削除)

画像と PDF は GCS に保存し、7日間有効の署名付き URL を生成して LINE に送る。バケット自体は非公開で、URL を知らない限りアクセスできない。

Cloud Run functions のサービスアカウントには以下の権限が必要。

  • roles/secretmanager.secretAccessor — シークレット読み取り
  • roles/secretmanager.secretVersionManager — refresh token の書き戻し
  • roles/storage.objectCreator — GCS への書き込み
  • roles/iam.serviceAccountTokenCreator — 署名付き URL 生成

Gmail watch() の7日制限

Gmail API の watch() は最大7日で期限切れになる。放っておくと通知が止まる。Cloud Scheduler で6日ごとに HTTP トリガーの renew_watch を呼び出して自動更新している。

resource "google_cloud_scheduler_job" "renew_watch" {
  name      = "renew-gmail-watch"
  schedule  = "0 0 */6 * *"
  time_zone = "Asia/Tokyo"

  http_target {
    uri         = google_cloudfunctions2_function.renew_watch.url
    http_method = "POST"
    oidc_token {
      service_account_email = google_service_account.function.email
    }
  }
}

OAuth refresh token のローテーション対応

Google は OAuth2 の refresh token を予告なくローテーションすることがある。creds.refresh() で新しい access token を取得する際、refresh token も新しいものへ置き換わる場合がある。古い refresh token は無効化されるため、新しいトークンを保存しないと認証が壊れる。

実際にこの問題で通知停止を経験しており、gmail_client.py でトークンの変更を検知して Secret Manager へ自動で書き戻すようにした。

creds.refresh(Request())

if creds.refresh_token and creds.refresh_token != original_refresh_token:
    self._update_refresh_token_secret(creds.refresh_token)

使ってみて

保育園から通知が来ると数秒で LINE に届く。以前はメール本文の先頭200文字がテキストで届くだけだったが、今は連絡の全文が読め、写真もそのまま表示される。PDF の行事予定表もワンタップで開ける。

妻と同じグループに入れているので、登園・降園の確認や保育園からの連絡共有がスムーズになった。