この記事は、個人開発の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側の設定
- GASエディタ左側の「サービス」から Gmail API を追加する
-
appsscript.jsonに、使う権限(oauthScopes)を 追記 する
{
"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)などが消えてしまいます。今ある内容に足してください。
- GASエディタの「プロジェクトの設定」→「スクリプト プロパティ」に、APIのURLと認証用トークンを保存する(コードには書かない)
GASでやっていること
GASのコードは、次のことをしているだけです。
- スクリプトプロパティから、APIのURLとトークンを読み込む
- Gmail APIで
label:メルマガ newer_than:2d(ラベル付き・2日以内)のメールを検索する - 見つかったメールを1通ずつ取り出し、文字だけの本文(text/plain) を探す
- メールは「文字だけの本文」と「HTMLの本文」が入れ子になっていることが多いので、奥まで順番に探す
- 本文と メールID(Gmailがメール1通ごとに付ける番号)を、トークン付きでCloud RunのAPIにPOSTする
- 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トークン)を付けるのは手間がかかるため、
共有シークレット(決まった合言葉) をヘッダーで送る簡易認証にしました。
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に保存する(二重登録を防ぐ)
テーブル
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は何行あっても重複扱いになりません。
保存処理
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にしかできないことだけ」を意識すると、個人開発でも安心して動かせる自動化になりました。
