はじめに
Azure を運用していると、不定期に「サービスの廃止」や「インスタンスの移行」に関するこのような↓通知メールが届くと思います。
これらは放置するとシステム停止などのトラブルに直結する重要なものですが、正直、以下のような悩みがありました。
- 英語で届くものも多いので、パッと見て「いつまでに何をすべきか」分かりにくい
- 複数のサブスクリプションを管理していると、どのリソースが対象か探すのが大変
- チーム内での共有や進捗管理がメールのままだとしにくい
そこで、GAS(Google Apps Script)、Gemini API、Notion API を組み合わせて、通知メールを自動で解析・整理する仕組みを作ってみたところなかなか便利だったので、記事化してみました!
Azure には「Service Retirement (サービスの廃止) ブック」があるにはあるのですが、先の通知メールと比較すると漏れていたりするし、プレビューがいつまでも取れないのもあり、やはりメール運用が今のところ一番確実という判断で、こういうことをしています。
システム構成
-
Gmail: Azureからの通知メール(
azure-noreply@microsoft.comなど)を受信し、特定のラベルを付与 - GAS: ラベルが付いた未読メールを取得
- Gemini API: メールの全文を解析し、「期限」「対象リソース」「対処手順」等をJSON形式で抽出。さらに日本語訳も生成
- Notion: 解析結果をデータベースの新規ページとして登録
- Slack: NotionのページURLと共に、概要を通知
準備するもの
- Gemini API Key: Google AI Studioから取得
- Notion API Token / Database ID: Notion から取得
-
GAS のスクリプトプロパティ設定:
- GMAIL_LABEL: メールが格納されている Gmail のラベル名
- NOTION_TOKEN: Notion のインテグレーション設定から取得した token
- NOTION_DB_ID: Notion のデータベースID
- GEMINI_API_KEY: Gemini の API キー
- GEMINI_URL: Gemini API の GenerateContent 用の URL(サクっとはこことここを見れば分かりそう)
- SLACK_EMAIL_ADDRESS: Slack 通知用メールアドレス
GEMINI_URL には、まずテスト的に https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent
辺りを設定してもらうと、コスト的な問題が起きにくいと思います。
問題ないことが確認できたら & 1.5 の回答だと物足りない感があったら、モデル調整をすると良いと思います。
SLACK_EMAIL_ADDRESS は、Slack のチャンネル用メールアドレスを想定して作っています。
Notion データベースの設定
以下のプロパティを持つデータベースを作成しておいてください。
| プロパティ名 | 型 | 備考 |
|---|---|---|
| タイトル | タイトル | メールの件名が入ります |
| ステータス | ステータス | 初期値「未確認」など |
| 概要 Gemini | テキスト | AIによる要約 |
| 期限 Gemini | テキスト | YYYY/MM/DD 形式 |
| 関連サービス Gemini | テキスト | 対象のリソース種別 |
| 関連サブスクリプション Gemini | テキスト | 対象のサブスクリプション名 |
スクリプト内のキー名("概要 Gemini" など)と、Notion 側のカラム名が 1 文字でも違うとうまく動かないので、プロパティ名は正確に一致させてください!
スクリプト
GAS で次のスクリプトを登録してください。
テスト的な実行が問題なければ、トリガーで定期的に実行するようにすると良いです。
コードを展開する
// Notion まわりは https://note.com/kawamura_/n/n66910a3d3743 を参考
const props = PropertiesService.getScriptProperties();
// Gmail まわり設定
const labelName = props.getProperty('GMAIL_LABEL');
const gmailReadSize = 10;
// Notion まわり設定
const token = props.getProperty('NOTION_TOKEN');
const dbId = props.getProperty('NOTION_DB_ID');
const notionUrl = "https://api.notion.com/v1/pages"
// Gemini まわり設定
const geminiApiKey = props.getProperty('GEMINI_API_KEY');
const geminiUrl = props.getProperty('GEMINI_URL');
// Email 送信まわり設定(Slack宛てを想定)
const slackEmailAddress = props.getProperty('SLACK_EMAIL_ADDRESS');
function main() {
const threads = GmailApp.search(`is:Unread label:${labelName}`, 0, gmailReadSize)
let i = 0;
threads.forEach((thread) => {
thread.getMessages().forEach((message) => {
if (!message.isUnread()) { return }
// Notion へ送る(Gemini の分析付き)
let newPage = JSON.parse(sendNotion(message));
// メールで送る(現状、宛先として Slack を想定)
sendEmail(message, newPage);
// 既読にする
message.markRead()
i++
})
})
console.log(`sent message number[${i}]`)
}
// Email 送信
function sendEmail(message, newPage){
// #azureからの要アクション通知 チャンネルへメール転送
let bodyText = `Notion の記事 URL: ${newPage.url}\n\n----- 元の本文は以下の通り -----\n\n${message.getPlainBody()}`;
return GmailApp.sendEmail(slackEmailAddress, message.getSubject(), bodyText, { name: 'azure-noreply@microsoft.com からの通知の転送' } );
}
function create_message(message) {
return `[Date] ${message.getDate()}`
+ `\n[From] ${message.getFrom()}`
+ `\n[Subject] ${message.getSubject()}`
+ `\n[Body]\n${message.getPlainBody()}`
}
// NotionDB への追加
function sendNotion(message) {
const headers = {
"Content-type": "application/json",
"Authorization": `Bearer ${token}`,
"Notion-Version": "2022-06-28"
}
// Gemini で分析
const geminiAnswer = analyzeMailByGemini(message.getPlainBody());
const data = {
"parent": {
"database_id": dbId
},
"properties":{
"タイトル": {
"title": [{
"text": {
"content": message.getSubject()
}
}]
},
"ステータス": {
"type": "status",
"status": {
"name": "未確認"
}
},
"概要 Gemini": {
"rich_text": [{
"type": "text",
"text": {
"content": geminiAnswer.abstract,
"link": null
}
}],
},
"期限 Gemini": {
"rich_text": [{
"type": "text",
"text": {
"content": geminiAnswer.limits,
"link": null
}
}],
},
"関連サービス Gemini": {
"rich_text": [{
"type": "text",
"text": {
"content": geminiAnswer.services,
"link": null
}
}],
},
"関連サブスクリプション Gemini": {
"rich_text": [{
"type": "text",
"text": {
"content": geminiAnswer.subscriptions,
"link": null
}
}],
},
// URL が多すぎるし、それだけだと分かりにくいので止めた
// "参考情報 Gemini": {
// "rich_text": [{
// "type": "text",
// "text": {
// "content": geminiAnswer.references,
// "link": null
// }
// }],
// },
},
"children": [
{
"object": "block",
"type": "heading_2",
"heading_2": {
"rich_text": [{ "type": "text", "text": { "content": "Azure からのメール" } }]
}
},
{
"object": "block",
"type": "toggle",
"toggle": {
"rich_text": [{
"type": "text",
"text": {
"content": "メール本文",
"link": null
}
}],
"color": "default",
"children": makeNotionParagraphs(splitTextForNotionText(message.getPlainBody()))
}
},
{
"object": "block",
"type": "toggle",
"toggle": {
"rich_text": [{
"type": "text",
"text": {
"content": "メール本文(日本語訳 by Gemini)",
"link": null
}
}],
"color": "default",
"children": makeNotionParagraphs(splitTextForNotionText(geminiAnswer.japanese))
}
},
{
"object": "block",
"type": "heading_2",
"heading_2": {
"rich_text": [{ "type": "text", "text": { "content": "対象リソースの調査" } }]
}
},
{
"object": "block",
"type": "toggle",
"toggle": {
"rich_text": [{
"type": "text",
"text": {
"content": "調査方法 by Gemini",
"link": null
}
}],
"color": "default",
"children": makeNotionParagraphs(splitTextForNotionText(geminiAnswer.research))
}
},
{
"object": "block",
"type": "heading_2",
"heading_2": {
"rich_text": [{ "type": "text", "text": { "content": "通知事項への対処" } }]
}
},
{
"object": "block",
"type": "toggle",
"toggle": {
"rich_text": [{
"type": "text",
"text": {
"content": "対処方法 by Gemini",
"link": null
}
}],
"color": "default",
"children": makeNotionParagraphs(splitTextForNotionText(geminiAnswer.solution))
}
},
]
}
const options = {
"method": "post",
"headers": headers,
"payload": JSON.stringify(data),
"muteHttpExceptions": true
}
const ret = UrlFetchApp.fetch(notionUrl, options)
if (ret.getResponseCode() !== 200) {
console.error(`Notion API Error: ${ret.getResponseCode()} / ${ret.getContentText()}`);
throw new Error("Notion への書き込みに失敗しました");
}
return ret.getContentText();
}
function analyzeMailByGemini(body){
const prompt = `以下の「## 解析対象」に記載した文章は Azure からの廃止や移行などの要対応事項を通知するメールになります。こちらの内容を解析して、次の内容をJSON 形式で返してください。各プロパティ値は指定がない限り2000文字以内に収めてください。JSON のテキスト値については、仕様に従って特殊文字についてはエスケープ処理をするようにしてください。
## JSON で返して欲しい内容
{
"abstract":"概要をまとめる(なるべく 200~300文字以内で。それ以内にまとめるのが難しい場合は 500文字まで可)",
"references":"対処に必要な情報が掲載されている URL が記載されていたら、それを抜粋(複数あれば改行で区切って全部を)。ただし「Help and support」以降の URL は無視すること。",
"limits":"対応の期限日(複数あれば改行で区切って全部を、YYYY/MM/DD 形式で)",
"services":"対応が必要な Azure リソースの種類(複数あれば全部を改行で区切って挙げる)",
"subscriptions":"対応が必要なリソースのサブスクリプション(複数あれば全部を改行で区切って挙げる。もし具体的な Azure リソース名があればそれも)",
"japanese":"本文が日本語でない場合に日本語に訳したもの(日本語の場合はここは空文字)。これは 2000文字を超えてもいいので、なるべく忠実に訳してください。",
"research":"本文+リンク先を参照などして、対象リソースを探す方法を調べてまとめる。これは 2000文字を超えてもいいので、なるべく正確で分かりやすい手順にまとめてください。",
"solution":"本文+リンク先を参照などして、対処方法・手順を調べてまとめる。これは 2000文字を超えてもいいので、なるべく正確で分かりやすい手順にまとめてください。"
}
## 解析対象
${body}
`;
const payload = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
response_mime_type: "application/json", // これを入れると Gemini のレスポンスに JSON を強制できる(とのこと)
}
};
const options = {
method: 'POST',
contentType: 'application/json',
headers: {
'x-goog-api-key': geminiApiKey,
},
payload: JSON.stringify(payload),
"muteHttpExceptions": true
};
const ret = UrlFetchApp.fetch(geminiUrl, options);
if (ret.getResponseCode() !== 200) {
console.error(`Gemini API Error: ${ret.getResponseCode()} / ${ret.getContentText()}`);
throw new Error("Gemini での解析に失敗しました");
}
const data = JSON.parse(ret.getContentText());
const content = data.candidates[0].content.parts[0].text;
// Gemini が返す JSON としての不整合発生部分対策(時々 escape が失敗していることがあるので、なるべく拾ってみた)
const contentJson = content.replace(/(?<!\\)\\(?!["\\/bfnrtu])/sg, '\\\\');
return JSON.parse(contentJson);
}
// Notionの文字数制限対策としてのテキスト分割ヘルパー
function splitTextForNotionText(text) {
if (!text) return [""]; // null や空文字なら空文字の配列を返す
return text.match(/[\s\S]{1,2000}/sg);
}
function makeNotionParagraphs(splitText) {
return splitText.map(line => {
return {
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [
{
"type": "text",
"text": {
"content": line,
"link": null
}
}
]
}
}
});
}
実行結果(notion側)
概要はこんな感じです。
本文はこんな感じです。
(Notion ではトグルを開くと詳細の内容が見られるようになっていますが、そこは長くなるので省略)
工夫したポイント
1. Gemini の「JSONモード」
最初はプロンプトで「JSONだけで返して!」と念押ししていました! 笑
その後、generationConfig: { response_mime_type: "application/json" } を使うことで、プログラムで扱いやすい純粋なJSON を安定して取得できるようになりました。
厳密には JSON の schema は GenerationConfig.ResponseSchema で指定した方が適切なんでしょうが…手抜きしています
2. Notion の文字数制限(2,000文字)への対応
メール全文の翻訳などは2,000文字を超えることが多いため、正規表現で分割して複数のパラグラフブロックとして登録するようにしています。
ただ今の実装は、文脈を無視して 2000 文字目で一律に区切っているので、その辺りはちょっと…なところがあるといえばあります。
最初は数行単位で区切ってみたのですが、そちらは区切りとしては綺麗なのですが、Notion でブロックが分かれるので、それはそれでちょっと見た目が悪くなったりで…
何かいい案あったら教えてください!
3. AIによる「調査方法」や「対処方法」の提示
単なる翻訳だけでなく、「どうやって対象リソースを探せばいいか(ポータルのどこを見ればいいか)」 や 「どうやって対処すればいいか」 を AI に参考情報も出させることで、エンジニアの作業時間を大幅に削減しています。
もちろん、実際には必ずしも完全な正解ばかりではありませんが、実運用上、十分に役立っています。
4. その他、細かいところ
- プロンプトで「Help and support 以降の URL は無視」と指定し、ノイズを削減
- Slack で通知が欲しかったので、Slack にもメール送信で通知(逆にこれ要らない人は削ってもらえれば)
コードの調整要素
Notion へ送る内容のリッチ化
今は大部分がベタなテキストにしちゃっているので、そこを頑張る!
(やりだすとキリがないですが…w)
一度にチェックするメールの量
サンプルコードでは次のように 10 通に絞っています。
ここは運用と実行時間のバランスで調整してみてください。
const gmailReadSize = 10;
API のレート制限対策
本スクリプトを大量のメールに対して実行すると、Notion APIのレート制限(秒間3リクエスト)やGemini APIの一時的なエラーに遭遇する可能性があります。
そのような時にも安定して動かすためには、UrlFetchApp.fetch() を直接呼ぶのではなく、指数バックオフ(Exponential Backoff) を用いたリトライ処理を行うという方法があります。
次のコードのようにエラーが発生した際に数秒待機してから再試行することで、不慮の実行失敗を劇的に減らすことができます。
コードを展開する
/**
* 指数バックオフを用いたフェッチ関数のラップ
*/
function fetchWithRetry(url, options, maxRetries = 3) {
let lastError;
for (let i = 0; i <= maxRetries; i++) {
try {
const response = UrlFetchApp.fetch(url, options);
const status = response.getResponseCode();
// 成功 (200) または 作成成功 (201)
if (status === 200 || status === 201) {
return response;
}
// レート制限(429)やサーバエラー(5xx)ならリトライ対象
if (status === 429 || status >= 500) {
console.warn(`Retry ${i+1}: Status ${status}. Waiting...`);
} else {
// 403(権限不足)や404などはリトライしても無駄なので即終了
return response;
}
} catch (e) {
lastError = e;
console.warn(`Retry ${i+1}: Exception ${e.message}`);
}
// 指数バックオフ: 2秒, 4秒, 8秒... と待ち時間を増やす
if (i < maxRetries) {
Utilities.sleep(Math.pow(2, i + 1) * 1000);
}
}
throw new Error(`最大リトライ回数を超過しました: ${lastError}`);
}
ただ、自動的に maxRetries までリトライするので、場合によってはGemini でトークンを無駄に消費する可能性もあるので、運用の厳密度などに応じて採用を判断してください。
(私の場合はここまで必要ないレベル&実運用上問題なかったので、これは採用していません)
おわりに
これで、Azureからの急な通知に震える必要がなくなりました。
Notionを見れば、やるべきことが日本語でまとまっている状態は非常に快適です。
皆さんの環境に合わせて、プロンプトを調整(例:自分のチーム独自の運用ルールを教え込むなど)して活用してみてください!



