1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gmailに届くメルマガを、読み取り専用権限のままDBに自動保存する(AIは要約だけ)

1
Last updated at Posted at 2026-09-24

この記事は、個人開発のBotで実装した「メルマガ自動取り込み」機能の紹介です。
特定のメルマガに依存しない、汎用的な作り方としてまとめています。

はじめに

毎日届くメルマガのうち、特定の部分だけをDBに入れたいと考えました。
ただ、毎回手でコピーしてDBに入れるのは面倒なので、Gmailに届いたら自動でDBに保存される仕組みを作りました。

ポイントは次の3つです。

  • Gmailへのアクセスは 読み取り専用(gmail.readonly) だけにする
  • 必要な部分は メールの決まった場所からそのまま切り出す。AIには 要約だけ を作ってもらう
  • 同じメールを何回処理しても DBに二重に入らない ようにする(メールIDと本文の2段チェック)

じつは最初は「必要な部分をAIに抜き出してもらう」作りにしていました。それで失敗したので、その話も書きます。

使った技術

役割 使ったもの
メールの受信 Gmail(フィルタでラベルを自動付与)
定期実行・メール読み取り Google Apps Script(GAS)+ Gmail API
受け取り側のAPI Cloud Run(Node.js / TypeScript / Hono)
要約の生成 Gemini API
DB PostgreSQL(Neon)+ Prisma

全体の流れ

役割は、次のように分けました。

やること
GAS Gmailからメールを探して、本文とメールIDをそのままCloud Runに送るだけ
Cloud Run どこを取り出すか決める・重複を調べる・DBに入れる・AIに要約を頼む

むずかしい処理は、すべてCloud Run側に書いています。
GASはGoogleのサイト上で動くので、自動テストが書きにくく、直すたびにエディタへ貼り直す必要があります。
Cloud Runなら、テストを書いて確かめられて、pushするだけで反映できます。今回も切り出しのルールを何度も直したので、この分け方にしておいてよかったです。

1. Gmail側の準備(手作業・1回だけ)

メルマガの送信元アドレスを条件に、Gmailのフィルタで 専用ラベル(例:メルマガ)を自動で付けるようにします。
こうしておくと、GASからは label:メルマガ で検索するだけで対象のメールが取れます。

フィルタでは 「迷惑メールにしない」にもチェック を入れておきます。
Gmail APIのメール検索は、迷惑メールとゴミ箱の中を最初から探しません。メルマガが迷惑メールに振り分けられると、ラベルが付いていても見つからなくなります(実際にこれで取り込めない日がありました)。

2. GASでメールを読む(読み取り専用)

ハマりどころ:GmailApp は読み取り専用にできない

GASでGmailを読むときは、組み込みの GmailApp.search() を使うのが一番簡単です。
ところが GmailApp は、https://mail.google.com/(読み書き・削除・送信まで全部できる権限)が必須 です。gmail.readonly だけでは動きません。

「メルマガを読むだけなのに、メールボックス全体を触れる権限を渡したくない」と考え、
Advanced Google Services の Gmail API を使う方法に切り替えました。こちらは本来のREST APIなので、gmail.readonly だけで動きます。

GAS側の設定

  1. GASエディタ左側の「サービス」から Gmail API を追加する
  2. appsscript.json に、使う権限(oauthScopes)を 追記 する
appsscript.json(追記する部分)
{
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.readonly",
    "https://www.googleapis.com/auth/script.external_request"
  ]
}

oauthScopes を書くと、GASはコードから権限を自動で判断するのをやめて、ここに書いたものだけを使います。
そのため、メールを読む権限(gmail.readonly)だけでなく、GASからCloud RunのAPIにデータを送る権限(script.external_request) も書く必要があります。
書き忘れると、APIに送るところ(UrlFetchApp.fetch)で権限エラーになります。
また、ファイルをこの内容でまるごと上書きすると、Gmail APIを有効にする設定(enabledAdvancedServices)などが消えてしまいます。今ある内容に足してください。

  1. GASエディタの「プロジェクトの設定」→「スクリプト プロパティ」に、APIのURLと認証用トークンを保存する(コードには書かない)

GASでやっていること

GASのコードは、次のことをしているだけです。

  1. スクリプトプロパティから、APIのURLとトークンを読み込む
  2. Gmail APIで label:メルマガ newer_than:2d(ラベル付き・2日以内)のメールを検索する
  3. 見つかったメールを1通ずつ取り出し、文字だけの本文(text/plain) を探す
    • メールは「文字だけの本文」と「HTMLの本文」が入れ子になっていることが多いので、奥まで順番に探す
  4. 本文と メールID(Gmailがメール1通ごとに付ける番号)を、トークン付きでCloud RunのAPIにPOSTする
  5. 1通で失敗しても、残りのメールの処理は止めない

文字だけの本文が無いメール(HTMLだけのメール)や、文字化けを直せなかったメールは、送らずに飛ばします。

ハマりどころ①:Could not decode string. エラー
Gmail APIのリファレンスには「本文はbase64url形式」と書かれていますが、それはREST APIを直接呼んだ場合の話です。
実際に動かしてみると、GASのAdvanced Gmail Serviceは すでにデコード済みのバイト配列 を返してきました。これをさらにbase64デコードしようとして、このエラーになりました。
そこで、文字列ならデコードし、バイト配列ならそのまま使うようにしました。

ハマりどころ②:文字化けして、エラーも出ずに0件追加になる
日本語のメルマガは、UTF-8ではない文字コード(ISO-2022-JP、Shift_JISなど)で届くことがあります。
UTF-8のつもりで読むと本文が ��� だらけになり、何も取り込めないのに「成功・0件追加」になります。エラーにならないので気づきにくいです。
対策として、まずメールのヘッダーに書かれた文字コードで読み、� が出たら日本語でよく使われる文字コードを順番に試すようにしました。これで、実際のメルマガが読めるようになりました。
ただし「� が出なければOK」という簡易的な判定なので、� が出ないまま化けるケースは見分けられません。

最後に、GASの「トリガー」から、このスクリプトを 毎日決まった時刻 に実行するよう設定します。

読み取り専用で「二重処理」をどう防ぐか

よくある作り方は、処理したメールに「処理済み」ラベルを付けることです。
でも、ラベルを付けるには 書き込み権限 が必要なので、今回は使えません。

そこで、次のように割り切りました。

  • 毎日、直近2日分だけ を検索する(範囲を絞って、ムダな再処理を減らす)
  • 同じメールが何回送られても、API側で重複チェック して、二重には追加しない(くわしくは5章)

こうすると、失敗したメールは翌日にもう1回だけ、自然と再挑戦される というおまけも付きます(検索範囲が2日分なので、再挑戦は1回まで)。

3. Cloud Run側:トークンで簡易認証する

GASからGoogle Cloudの本格的な認証(OIDCトークン)を付けるのは手間がかかるため、
共有シークレット(決まった合言葉) をヘッダーで送る簡易認証にしました。

src/index.ts
app.post("/internal/quote-ingest", async (c) => {
  const authHeader = c.req.header("authorization");
  if (!QUOTE_INGEST_TOKEN || authHeader !== `Bearer ${QUOTE_INGEST_TOKEN}`) {
    return c.text("Unauthorized", 401);
  }

  const body = (await c.req.json()) as { emailText?: string; gmailMessageId?: unknown };
  if (!body.emailText) {
    return c.text("Bad Request", 400);
  }
  // 空文字などが届いたときは「メールIDなし」として扱う
  const gmailMessageId =
    typeof body.gmailMessageId === "string" && body.gmailMessageId.trim() !== "" ? body.gmailMessageId.trim() : null;

  try {
    const result = await processQuoteIngest(deps, body.emailText, gmailMessageId);
    return c.json(result, 200);
  } catch (error) {
    console.error("[quote-ingest] 処理中にエラー", error);
    return c.text("Internal Server Error", 500);
  }
});

トークンが未設定のときは 必ず401 になるようにしています(設定ミスで誰でも叩けてしまう状態を防ぐため)。
トークン自体は Secret Manager 経由で環境変数に渡しています。
より安全にするなら、!== ではなく crypto.timingSafeEqual で比べると、比べる時間の差からトークンを推測される心配がなくなります。

4. Cloud Run側:必要な部分を、決まった場所から切り出す

最初はAIに抜き出してもらっていた

メルマガには、DBに入れたい部分のほかに、あいさつ文・署名・広告・配信停止の案内なども入っています。
最初は「メルマガの書式が変わっても対応できるように」と考えて、AIに必要な部分を抜き出してもらう 作りにしていました。

ところが、動かしてみると次の問題が起きました。

  • 同じメールから、毎回少しずつちがう文章が抜き出される
    GASは直近2日分を毎日送り直すので、同じメールがAPIに何度も届きます。AIは毎回、抜き出す範囲を少しずつ変えてきました。本文が1文字でもちがうと重複チェックをすり抜けるので、同じ話が何行も登録されました。
  • タイトルをAIが言いかえてしまう
    メールのタイトルと、DBのタイトルが一致しません。これでは、DBの行から元のメールを探すこともできません。
  • 広告のメールからも、何かを抜き出してしまう

決まった場所から切り出す方式に変えた

今回のメルマガは、毎回同じ体裁 で届きます。そこで、AIは使わずに、目印をたよりに決まった場所から切り出すことにしました。

9/24 今日のタイトル           ← 日付の後ろがタイトル
━━━━━━━━━━━━━━━━━━━━

本文の1行目…                   ← ここから
…
(著者名)                      ← ここまでが本文

--------------------------------
本日の出典は
『書籍名』第〇章です。          ← 出典
取り出すもの 目印
タイトル 「日付+空白+文字」の行で、すぐ下に太線がある行。日付の後ろの部分
本文 タイトルの下の太線の次の行から、署名(著者名だけの行)まで
出典 「本日の出典は」と「です。」にはさまれた部分

中身の文字ではなく、毎回変わらない目印 で探すので、タイトルや本文が毎日変わっても取り出せます。

こうすると、

  • 同じメールからは、何回やっても1文字もちがわない結果になる → 重複チェックが確実に効く
  • タイトル・本文がメールのまま → DBの行から元のメールをたどれる
  • 体裁がちがうメール(広告など)は、目印が見つからないので0件 になる

AIに作ってもらうのは、元のメールには無い 要約(50字程度)だけ にしました。

決まった場所から切り出す方式の弱点
メルマガの体裁が変わると、目印が見つからずに0件になります。まちがった文章が入ることはありませんが、取りこぼしたことに気づきにくいです。
ただ、体裁が変わることはめったにないので、毎日の正確さを優先しました。取りこぼしが心配なら、「タイトル行はあるのに本文が取れなかったとき」だけ自分に通知する、といった仕組みを足すとよいです。

AIを使うかどうかは、「毎回同じ結果がほしいか」で決めるとよいと思います。書式が決まっているなら、決まった場所から取るほうが確実です。

5. Cloud Run側:DBに保存する(二重登録を防ぐ)

テーブル

prisma/schema.prisma
model Quote {
  quoteId        String   @id @default(cuid()) @map("quote_id")
  title          String
  summary        String?  // AIで作る要約。作成前はnull
  body           String
  source         String?
  // 取り込み元のGmailのメールID。手で登録した行などはnull
  gmailMessageId String?  @unique @map("gmail_message_id")
  createdAt      DateTime @default(now()) @map("created_at")

  @@map("quotes")
}

gmailMessageId は nullable(空でもOK) にしています。この列を足す前から入っていた行や、手で登録した行にはメールIDが無いためです。
@unique(同じ値は1つだけ)を付けていますが、PostgreSQLでは nullは何行あっても重複扱いになりません。

保存処理

src/handlers/quoteIngest.ts
export async function processQuoteIngest(deps: AppDeps, emailText: string, gmailMessageId: string | null) {
  // 重複チェック①:このメールIDから登録済みなら、何もしない
  if (gmailMessageId) {
    const alreadyIngested = await deps.prisma.quote.findUnique({ where: { gmailMessageId } });
    if (alreadyIngested) return { added: 0 };
  }

  // 決まった場所から切り出す。体裁がちがうメール(広告など)はnull
  const candidate = parseNewsletter(emailText);
  if (!candidate) return { added: 0 };

  // 重複チェック②:同じ本文が既にあれば、何もしない
  const existing = await deps.prisma.quote.findFirst({ where: { body: candidate.body } });
  if (existing) return { added: 0 };

  const quote = await deps.prisma.quote.create({
    data: { title: candidate.title, body: candidate.body, source: candidate.source, gmailMessageId },
  });

  // 要約の生成に失敗しても、本文の保存は取り消さない
  try {
    const summary = await deps.quoteExtractionClient.generateQuoteSummary(candidate);
    if (summary) {
      await deps.prisma.quote.update({ where: { quoteId: quote.quoteId }, data: { summary } });
    }
  } catch (error) {
    console.error(`quote_id=${quote.quoteId} の要約生成に失敗`, error);
  }
  return { added: 1 };
}

重複チェックを2段にしているのは、それぞれ防げるものがちがうからです。

チェック 防げるもの
① メールID 同じメールが何度届いても、1回しか処理しない。切り出しのルールを直したあとでも、登録済みのメールが別の形で登録し直されることがない
② 本文 メールIDを持たない行(手で登録した行など)と同じ内容が届いたとき

実際に、同じ2日分のメールを2回続けて取り込んでみて、2回目はすべて0件になることを確かめました。

失敗したときの対応

失敗したときの対応は、どこで失敗したか によって変えています。

どこで失敗したか どうするか なぜそれでいいか
体裁がちがうメール(広告など) エラーにせず、「0件追加」で終わる そもそも取り込む対象ではない
DBの操作など エラー(500)を返す GASは直近2日分を毎日送ってくるので、次の日にもう一度試される
要約づくり 本文は保存したまま、要約だけ空にしておく 本文さえあれば、要約はあとから作れる

要約(summary)が空のまま残った行は、「要約が空の行を探して、まとめて要約を作る」APIを別に用意して、あとから埋められるようにしています。

まとめ

  • GASの GmailApp は読み取り専用にできないので、Gmail API(Advanced Service) を使う
  • 書き込み権限が無いので、「処理済みラベル」ではなく 「直近N日だけ検索 + API側で重複チェック」 で二重処理を防ぐ
  • 書式が決まっているメールなら、必要な部分は AIに抜き出させず、決まった場所から切り出す。AIは毎回ちがう結果を返すので、重複チェックがすり抜けてしまう
  • AIには、元のメールに無いもの(要約)だけ を作ってもらう
  • 重複チェックは メールIDと本文の2段 にする

「権限は最小限に」「AIに任せるのは、AIにしかできないことだけ」を意識すると、個人開発でも安心して動かせる自動化になりました。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?