メール・予定・資料が、それぞれ別々に賢くなっている
Google Workspace を使った業務では、メールを確認し、カレンダーに予定を入れ、Drive にドキュメントを保存する、という一連の作業が毎日繰り返されます。個々のツールに Gemini AI を組み込む方法は多くの記事で紹介されていますが、これらを 一本のパイプラインとして統合 し、人手をほとんど介さずに回す方法はあまり語られていません。
Google Apps Script(GAS)と Gemini API を組み合わせると、Gmail の受信メールを AI でトリアージ → 必要な予定を Calendar に自動登録 → 関連資料を Drive から検索・要約して事前準備ノートを作成 という一気通貫のワークフローを構築します。
対象読者は、Apps Script の基本構文を理解しており、Gemini API を少なくとも一度は呼び出したことがある中〜上級者です。本番環境で安定稼働させるためのトリガー設計やエラーハンドリング、Google API のクォータ管理についても詳しく解説します。
全体アーキテクチャ — 3 つのフェーズで構成するパイプライン
ワークフロー全体は以下の 3 フェーズで動作します。
Phase 1 — メールトリアージ(Gmail → Gemini API)
受信トレイの未読メールを取得し、Gemini API に送信して「緊急度」「カテゴリ」「必要なアクション」を構造化データとして返してもらいます。結果に基づいてラベルを自動付与し、アーカイブや返信ドラフトの作成を行います。
Phase 2 — カレンダー登録(Gemini → Calendar API)
Phase 1 で「会議招集」「締切通知」と分類されたメールについて、Gemini API に日時・参加者・議題の抽出を依頼し、Calendar API で予定を自動登録します。既存の予定と衝突する場合は代替候補を提案するロジックも組み込みます。
Phase 3 — 事前準備ノート生成(Drive → Gemini → Docs)
Phase 2 で登録した予定に関連するドキュメントを Drive から検索し、Gemini API で要約・論点整理を行った上で Google Docs に「事前準備ノート」として出力します。会議開始 30 分前に参加者へメールで通知する仕組みも実装します。
環境準備 — API キーとスコープの設定
Gemini API キーの取得
Google AI Studio で API キーを発行します。Apps Script からは UrlFetchApp で REST API を直接呼び出すため、OAuth は不要です。
// PropertiesService にAPIキーを安全に保存
function setApiKey () {
PropertiesService. getScriptProperties (). setProperty (
'GEMINI_API_KEY' ,
'YOUR_GEMINI_API_KEY' // ← Google AI Studio で発行したキーに置換
);
}
function getApiKey () {
return PropertiesService. getScriptProperties (). getProperty ( 'GEMINI_API_KEY' );
}
OAuth スコープの宣言
appsscript.json の oauthScopes に以下を明示的に宣言します。スコープを最小限に絞ることで、デプロイ時の審査をスムーズに通過できます。
// appsscript.json
{
"timeZone" : "Asia/Tokyo" ,
"dependencies" : {},
"exceptionLogging" : "STACKDRIVER" ,
"runtimeVersion" : "V8" ,
"oauthScopes" : [
"https://www.googleapis.com/auth/gmail.modify" ,
"https://www.googleapis.com/auth/calendar" ,
"https://www.googleapis.com/auth/drive.readonly" ,
"https://www.googleapis.com/auth/documents" ,
"https://www.googleapis.com/auth/script.external_request"
]
}
gmail.modify(読み取りだけでなくラベル操作も行うため)、drive.readonly(検索・閲覧のみ)、documents(Docs の作成・書き込み)がポイントです。
Phase 1 — Gemini API によるメール自動トリアージ
メール取得とバッチ処理
Apps Script の GmailApp で未読メールを取得し、Gemini API に渡すバッチを作成します。Gemini API のレート制限(無料枠: 15 RPM)を考慮して、最大 10 件ずつ処理します。
/**
* 未読メールを取得し、Gemini API でトリアージする
* @returns {Array<Object>} トリアージ結果の配列
*/
function triageEmails () {
const threads = GmailApp. search ( 'is:unread -category:promotions -category:social' , 0 , 10 );
const results = [];
for ( const thread of threads) {
const messages = thread. getMessages ();
const latest = messages[messages. length - 1 ];
const emailData = {
subject: latest. getSubject (),
from: latest. getFrom (),
body: latest. getPlainBody (). substring ( 0 , 2000 ), // トークン節約のため先頭2000文字
date: latest. getDate (). toISOString ()
};
const triage = callGeminiForTriage (emailData);
if (triage) {
results. push ({
threadId: thread. getId (),
messageId: latest. getId (),
... triage,
originalEmail: emailData
});
applyTriageActions (thread, triage);
}
// レート制限対策: 1リクエストごとに4秒待機(15 RPM = 4秒/リクエスト)
Utilities. sleep ( 4000 );
}
return results;
}
Gemini API 呼び出し — 構造化出力でトリアージ結果を取得
Gemini API の response_mime_type: "application/json" を使って、型安全な構造化出力を取得します。
/**
* Gemini API にメールを送信してトリアージ結果を取得する
* @param {Object} email - メールデータ
* @returns {Object|null} トリアージ結果
*/
function callGeminiForTriage ( email ) {
const apiKey = getApiKey ();
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${ apiKey }` ;
const prompt = `あなたはメールトリアージの専門家です。以下のメールを分析し、JSON形式で結果を返してください。
メール情報:
- 件名: ${ email . subject }
- 差出人: ${ email . from }
- 日時: ${ email . date }
- 本文: ${ email . body }
以下のJSONスキーマに従って出力してください:
{
"urgency": "high" | "medium" | "low",
"category": "meeting" | "deadline" | "request" | "info" | "spam",
"action": "reply_needed" | "calendar_entry" | "archive" | "follow_up",
"summary": "1-2文の要約",
"suggested_reply": "返信が必要な場合のドラフト(不要ならnull)",
"calendar_info": {
"needed": true/false,
"title": "予定タイトル",
"datetime": "ISO 8601形式の日時",
"duration_minutes": 60,
"attendees": ["email1@example.com"]
}
}` ;
const payload = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: 'application/json' ,
temperature: 0.1 // 分類タスクは低温で安定させる
}
};
try {
const response = UrlFetchApp. fetch (url, {
method: 'post' ,
contentType: 'application/json' ,
payload: JSON . stringify (payload),
muteHttpExceptions: true
});
const status = response. getResponseCode ();
if (status !== 200 ) {
console. error ( `Gemini API error: ${ status } - ${ response . getContentText () }` );
return null ;
}
const json = JSON . parse (response. getContentText ());
const text = json.candidates[ 0 ].content.parts[ 0 ].text;
return JSON . parse (text);
} catch (e) {
console. error ( `Triage failed: ${ e . message }` );
return null ;
}
}
トリアージ結果に基づくアクション実行
/**
* トリアージ結果に基づいてGmailのラベル付与・アーカイブ・ドラフト作成を実行
*/
function applyTriageActions ( thread , triage ) {
// ラベルの取得または作成
const labelName = `AI-Triage/${ triage . urgency }` ;
let label = GmailApp. getUserLabelByName (labelName);
if ( ! label) {
label = GmailApp. createLabel (labelName);
}
thread. addLabel (label);
// カテゴリ別ラベルも付与
const catLabel = GmailApp. getUserLabelByName ( `AI-Category/${ triage . category }` )
|| GmailApp. createLabel ( `AI-Category/${ triage . category }` );
thread. addLabel (catLabel);
// 低優先度の情報メールは自動アーカイブ
if (triage.urgency === 'low' && triage.category === 'info' ) {
thread. moveToArchive ();
thread. markRead ();
}
// 返信ドラフトの自動作成
if (triage.suggested_reply && triage.action === 'reply_needed' ) {
const messages = thread. getMessages ();
const latest = messages[messages. length - 1 ];
latest. createDraftReply (triage.suggested_reply);
}
}
Phase 2 — AI による Calendar 予定の自動登録
Phase 1 で calendar_info.needed === true と判定されたメールについて、Calendar API で予定を登録します。
既存予定との衝突チェック
/**
* 指定日時に既存の予定がないか確認し、衝突する場合は代替時間を提案する
* @param {string} dateTimeStr - ISO 8601 形式の日時
* @param {number} durationMin - 所要時間(分)
* @returns {Object} { available: boolean, suggestedTime: Date }
*/
function checkCalendarAvailability ( dateTimeStr , durationMin ) {
const startTime = new Date (dateTimeStr);
const endTime = new Date (startTime. getTime () + durationMin * 60 * 1000 );
const calendar = CalendarApp. getDefaultCalendar ();
const events = calendar. getEvents (startTime, endTime);
if (events. length === 0 ) {
return { available: true , suggestedTime: startTime };
}
// 衝突がある場合: 当日の空き時間を探す
const dayStart = new Date (startTime);
dayStart. setHours ( 9 , 0 , 0 , 0 );
const dayEnd = new Date (startTime);
dayEnd. setHours ( 18 , 0 , 0 , 0 );
const allEvents = calendar. getEvents (dayStart, dayEnd);
const busySlots = allEvents. map ( e => ({
start: e. getStartTime (). getTime (),
end: e. getEndTime (). getTime ()
})). sort (( a , b ) => a.start - b.start);
// 空きスロットを検索
let candidateStart = dayStart. getTime ();
for ( const slot of busySlots) {
if (slot.start - candidateStart >= durationMin * 60 * 1000 ) {
return { available: false , suggestedTime: new Date (candidateStart) };
}
candidateStart = Math. max (candidateStart, slot.end);
}
// 当日の空きがなければ翌営業日の9時を提案
const nextDay = new Date (startTime);
nextDay. setDate (nextDay. getDate () + 1 );
if (nextDay. getDay () === 0 ) nextDay. setDate (nextDay. getDate () + 1 );
if (nextDay. getDay () === 6 ) nextDay. setDate (nextDay. getDate () + 2 );
nextDay. setHours ( 9 , 0 , 0 , 0 );
return { available: false , suggestedTime: nextDay };
}
予定の自動登録
/**
* トリアージ結果から Calendar 予定を作成する
* @param {Object} triageResult - Phase 1 のトリアージ結果
* @returns {Object|null} 作成した予定の情報
*/
function createCalendarEvent ( triageResult ) {
const calInfo = triageResult.calendar_info;
if ( ! calInfo || ! calInfo.needed) return null ;
const duration = calInfo.duration_minutes || 60 ;
const availability = checkCalendarAvailability (calInfo.datetime, duration);
const eventStart = availability.suggestedTime;
const eventEnd = new Date (eventStart. getTime () + duration * 60 * 1000 );
const calendar = CalendarApp. getDefaultCalendar ();
const event = calendar. createEvent (
calInfo.title || triageResult.summary,
eventStart,
eventEnd,
{
description: `[AI自動登録] \n\n 元メール要約: ${ triageResult . summary } \n\n 差出人: ${ triageResult . originalEmail . from }` ,
guests: (calInfo.attendees || []). join ( ',' )
}
);
// 衝突があった場合はリマインダーに注記
if ( ! availability.available) {
event. setDescription (
event. getDescription () + ` \n\n ⚠️ 元の希望日時(${ calInfo . datetime })から変更されています。`
);
}
// 30分前リマインダーを設定
event. addPopupReminder ( 30 );
return {
eventId: event. getId (),
title: event. getTitle (),
start: eventStart. toISOString (),
end: eventEnd. toISOString (),
rescheduled: ! availability.available
};
}
Phase 3 — Drive 検索 + Gemini 要約で事前準備ノートを自動生成
関連ドキュメントの検索
登録した予定のタイトルと参加者情報をもとに、Google Drive から関連ドキュメントを検索します。
/**
* 予定に関連するDriveドキュメントを検索する
* @param {Object} eventInfo - Phase 2 で作成した予定の情報
* @param {Object} triageResult - Phase 1 のトリアージ結果
* @returns {Array<Object>} 関連ドキュメントのリスト
*/
function searchRelatedDocs ( eventInfo , triageResult ) {
// Gemini API でドキュメント検索クエリを生成
const searchQuery = generateSearchQuery (eventInfo, triageResult);
const files = DriveApp. searchFiles (
`fullText contains '${ searchQuery }' and mimeType != 'application/vnd.google-apps.folder' and trashed = false`
);
const results = [];
let count = 0 ;
while (files. hasNext () && count < 5 ) {
const file = files. next ();
results. push ({
id: file. getId (),
name: file. getName (),
mimeType: file. getMimeType (),
url: file. getUrl (),
lastUpdated: file. getLastUpdated (). toISOString ()
});
count ++ ;
}
return results;
}
/**
* Gemini API にドキュメント検索キーワードを生成させる
*/
function generateSearchQuery ( eventInfo , triageResult ) {
const apiKey = getApiKey ();
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${ apiKey }` ;
const prompt = `以下の会議情報から、Google Drive で関連ドキュメントを検索するための最適な検索キーワード(1フレーズ、最大5語)を生成してください。キーワードのみを返してください。
会議タイトル: ${ eventInfo . title }
メール要約: ${ triageResult . summary }
差出人: ${ triageResult . originalEmail . from }` ;
const payload = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.2 , maxOutputTokens: 50 }
};
const response = UrlFetchApp. fetch (url, {
method: 'post' ,
contentType: 'application/json' ,
payload: JSON . stringify (payload),
muteHttpExceptions: true
});
const json = JSON . parse (response. getContentText ());
return json.candidates[ 0 ].content.parts[ 0 ].text. trim ();
}
ドキュメント内容の要約と準備ノート生成
/**
* 関連ドキュメントの内容をGemini APIで要約し、Google Docsに事前準備ノートを作成する
* @param {Object} eventInfo - 予定情報
* @param {Array<Object>} relatedDocs - 関連ドキュメントリスト
* @param {Object} triageResult - トリアージ結果
* @returns {string} 作成したDocsのURL
*/
function createPrepNote ( eventInfo , relatedDocs , triageResult ) {
// 各ドキュメントの内容を取得(テキスト抽出可能なもののみ)
const docContents = relatedDocs. map ( doc => {
try {
if (doc.mimeType === 'application/vnd.google-apps.document' ) {
const content = DocumentApp. openById (doc.id). getBody (). getText ();
return { name: doc.name, content: content. substring ( 0 , 3000 ) };
}
if (doc.mimeType === 'application/pdf' || doc.mimeType. startsWith ( 'text/' )) {
const blob = DriveApp. getFileById (doc.id). getBlob ();
return { name: doc.name, content: blob. getDataAsString (). substring ( 0 , 3000 ) };
}
return { name: doc.name, content: '(バイナリファイル — 内容抽出不可)' };
} catch (e) {
return { name: doc.name, content: `(読み取りエラー: ${ e . message })` };
}
});
// Gemini API で事前準備ノートを生成
const noteContent = callGeminiForPrepNote (eventInfo, docContents, triageResult);
// Google Docs に出力
const doc = DocumentApp. create ( `📋 事前準備ノート: ${ eventInfo . title }` );
const body = doc. getBody ();
body. appendParagraph ( `事前準備ノート: ${ eventInfo . title }` )
. setHeading (DocumentApp.ParagraphHeading. HEADING1 );
body. appendParagraph ( `日時: ${ new Date ( eventInfo . start ). toLocaleString ( 'ja-JP' ) }` );
body. appendParagraph ( `作成日時: ${ new Date (). toLocaleString ( 'ja-JP' ) }(AI自動生成)` );
body. appendHorizontalRule ();
body. appendParagraph (noteContent);
// 参照ドキュメントへのリンク
body. appendParagraph ( '参照ドキュメント' ). setHeading (DocumentApp.ParagraphHeading. HEADING2 );
for ( const d of relatedDocs) {
body. appendListItem (d.name). setLinkUrl (d.url);
}
doc. saveAndClose ();
return doc. getUrl ();
}
/**
* Gemini API に事前準備ノートの内容を生成させる
*/
function callGeminiForPrepNote ( eventInfo , docContents , triageResult ) {
const apiKey = getApiKey ();
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?key=${ apiKey }` ;
const docsSection = docContents. map ( d =>
`【${ d . name }】 \n ${ d . content }`
). join ( ' \n\n --- \n\n ' );
const prompt = `あなたは優秀なビジネスアシスタントです。以下の会議情報と関連ドキュメントをもとに、参加者が効率的に会議に臨めるよう事前準備ノートを作成してください。
## 会議情報
- タイトル: ${ eventInfo . title }
- 日時: ${ eventInfo . start }
- 背景: ${ triageResult . summary }
## 関連ドキュメント
${ docsSection }
## 出力形式
1. **会議の目的と背景**(2-3文)
2. **議論すべきポイント**(3-5項目の箇条書き)
3. **各ドキュメントの要点**(ドキュメントごとに2-3文)
4. **確認・決定が必要な事項**(箇条書き)
5. **事前に用意しておくべき資料・データ**(あれば)
簡潔で実用的なノートを作成してください。` ;
const payload = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3 , maxOutputTokens: 2000 }
};
const response = UrlFetchApp. fetch (url, {
method: 'post' ,
contentType: 'application/json' ,
payload: JSON . stringify (payload),
muteHttpExceptions: true
});
const json = JSON . parse (response. getContentText ());
return json.candidates[ 0 ].content.parts[ 0 ].text;
}
パイプライン統合 — メインオーケストレーター
3 つのフェーズを統合するメイン関数を作成します。
/**
* メインオーケストレーター: Phase 1 → Phase 2 → Phase 3 を順次実行
*/
function runWorkflowPipeline () {
const startTime = new Date ();
console. log ( `=== AI Workflow Pipeline 開始: ${ startTime . toISOString () } ===` );
// Phase 1: メールトリアージ
console. log ( 'Phase 1: メールトリアージ開始' );
const triageResults = triageEmails ();
console. log ( `Phase 1 完了: ${ triageResults . length }件処理` );
// Phase 2: Calendar 予定登録
console. log ( 'Phase 2: Calendar 予定登録開始' );
const calendarEntries = [];
for ( const result of triageResults) {
if (result.calendar_info && result.calendar_info.needed) {
const eventInfo = createCalendarEvent (result);
if (eventInfo) {
calendarEntries. push ({ event: eventInfo, triage: result });
}
Utilities. sleep ( 4000 ); // レート制限対策
}
}
console. log ( `Phase 2 完了: ${ calendarEntries . length }件の予定を登録` );
// Phase 3: 事前準備ノート生成
console. log ( 'Phase 3: 事前準備ノート生成開始' );
const prepNotes = [];
for ( const entry of calendarEntries) {
const docs = searchRelatedDocs (entry.event, entry.triage);
if (docs. length > 0 ) {
const noteUrl = createPrepNote (entry.event, docs, entry.triage);
prepNotes. push ({ event: entry.event.title, noteUrl });
}
Utilities. sleep ( 4000 );
}
console. log ( `Phase 3 完了: ${ prepNotes . length }件のノート作成` );
// 実行サマリーをログに出力
const elapsed = ( new Date () - startTime) / 1000 ;
const summary = {
executionTime: `${ elapsed }秒` ,
emailsProcessed: triageResults. length ,
eventsCreated: calendarEntries. length ,
prepNotesGenerated: prepNotes. length ,
notes: prepNotes
};
console. log ( `=== Pipeline 完了 === \n ${ JSON . stringify ( summary , null , 2 ) }` );
// 実行結果を自分にメール送信(オプション)
if (prepNotes. length > 0 ) {
sendExecutionReport (summary);
}
return summary;
}
/**
* 実行レポートをメールで送信
*/
function sendExecutionReport ( summary ) {
const notesList = summary.notes. map ( n => `• ${ n . event }: ${ n . noteUrl }` ). join ( ' \n ' );
GmailApp. sendEmail (
Session. getActiveUser (). getEmail (),
`[AI Workflow] ${ summary . eventsCreated }件の予定登録 / ${ summary . prepNotesGenerated }件のノート作成` ,
`AI Workflow Pipeline 実行結果 \n\n 処理メール数: ${ summary . emailsProcessed } \n 登録予定数: ${ summary . eventsCreated } \n 作成ノート数: ${ summary . prepNotesGenerated } \n 実行時間: ${ summary . executionTime } \n\n 作成されたノート: \n ${ notesList }`
);
}
トリガー設計 — 安定運用のための自動実行設定
時間ベーストリガーの設定
/**
* 初回セットアップ: トリガーを登録する
* Apps Script エディタから手動で1回だけ実行する
*/
function setupTriggers () {
// 既存のトリガーを削除(重複防止)
const triggers = ScriptApp. getProjectTriggers ();
for ( const trigger of triggers) {
if (trigger. getHandlerFunction () === 'runWorkflowPipeline' ) {
ScriptApp. deleteTrigger (trigger);
}
}
// 平日の朝8時に実行(毎日1回)
ScriptApp. newTrigger ( 'runWorkflowPipeline' )
. timeBased ()
. everyDays ( 1 )
. atHour ( 8 )
. nearMinute ( 0 )
. create ();
// 昼12時にも実行(午前中のメールをキャッチ)
ScriptApp. newTrigger ( 'runWorkflowPipeline' )
. timeBased ()
. everyDays ( 1 )
. atHour ( 12 )
. nearMinute ( 0 )
. create ();
console. log ( 'トリガーを設定しました(8:00 / 12:00 の毎日2回実行)' );
}
実行時間制限への対策
Apps Script には 1 回の実行で 6 分(無料アカウント)/ 30 分(Workspace アカウント)の制限があります。大量のメールを処理する場合は、以下のように実行時間を監視して安全に中断します。
const MAX_EXECUTION_MS = 5 * 60 * 1000 ; // 5分(余裕を持たせる)
function isTimeUp ( startTime ) {
return ( new Date () - startTime) > MAX_EXECUTION_MS ;
}
/**
* 時間制限を考慮したメールトリアージ(改良版)
*/
function triageEmailsWithTimeLimit () {
const startTime = new Date ();
const threads = GmailApp. search ( 'is:unread -category:promotions -category:social' , 0 , 20 );
const results = [];
let processedCount = 0 ;
for ( const thread of threads) {
if ( isTimeUp (startTime)) {
console. log ( `時間制限に到達: ${ processedCount }/${ threads . length }件処理済み` );
// 未処理分は次回の実行で処理される(未読のまま残るため)
break ;
}
const messages = thread. getMessages ();
const latest = messages[messages. length - 1 ];
const emailData = {
subject: latest. getSubject (),
from: latest. getFrom (),
body: latest. getPlainBody (). substring ( 0 , 2000 ),
date: latest. getDate (). toISOString ()
};
const triage = callGeminiForTriage (emailData);
if (triage) {
results. push ({ threadId: thread. getId (), ... triage, originalEmail: emailData });
applyTriageActions (thread, triage);
}
processedCount ++ ;
Utilities. sleep ( 4000 );
}
return results;
}
エラーハンドリングとリトライ戦略
本番環境では、API のレート制限、一時的なネットワーク障害、トークン上限超過など、さまざまなエラーが発生します。ここでは堅牢なリトライ機構を実装します。
/**
* 指数バックオフ付きリトライラッパー
* @param {Function} fn - 実行する関数
* @param {number} maxRetries - 最大リトライ回数
* @returns {any} 関数の戻り値
*/
function withRetry ( fn , maxRetries = 3 ) {
let lastError;
for ( let attempt = 0 ; attempt <= maxRetries; attempt ++ ) {
try {
return fn ();
} catch (e) {
lastError = e;
console. warn ( `試行 ${ attempt + 1 }/${ maxRetries + 1 } 失敗: ${ e . message }` );
if (attempt < maxRetries) {
const backoff = Math. pow ( 2 , attempt) * 1000 + Math. random () * 1000 ;
console. log ( `${ Math . round ( backoff / 1000 ) }秒後にリトライ...` );
Utilities. sleep (backoff);
}
}
}
throw new Error ( `${ maxRetries + 1 }回の試行すべてが失敗: ${ lastError . message }` );
}
// 使用例
function callGeminiWithRetry ( email ) {
return withRetry (() => callGeminiForTriage (email), 3 );
}
クォータ管理ダッシュボード
/**
* 今日のAPI使用量をPropertiesServiceで追跡する
*/
function trackApiUsage () {
const props = PropertiesService. getScriptProperties ();
const today = new Date (). toISOString (). split ( 'T' )[ 0 ];
const key = `api_usage_${ today }` ;
const current = parseInt (props. getProperty (key) || '0' );
props. setProperty (key, String (current + 1 ));
// 1日のリクエスト上限に近づいたら警告(無料枠: 1500 RPD)
if (current + 1 >= 1400 ) {
console. warn ( `⚠️ API使用量警告: ${ current + 1 }/1500 RPD` );
}
return current + 1 ;
}
セキュリティとプライバシーの考慮事項
業務メールを外部 API に送信するため、セキュリティ面の配慮が不可欠です。
API キーの管理 : PropertiesService に保存し、コードにハードコードしません。チームで共有する場合は、GCP のシークレットマネージャーとの連携も検討してください。
送信データの最小化 : メール本文は先頭 2,000 文字に制限し、添付ファイルは送信しません。機密性の高いメール(特定のラベルや送信元)をフィルタで除外するロジックを追加することも推奨します。
ログの取り扱い : Stackdriver ログにメール内容が残らないよう、ログ出力では要約のみを記録します。
/**
* 機密メールを処理対象から除外するフィルタ
*/
function isConfidential ( email ) {
const confidentialDomains = [ 'legal@' , 'hr@' , 'finance@' ];
const confidentialKeywords = [ 'confidential' , '機密' , '社外秘' ];
if (confidentialDomains. some ( d => email.from. includes (d))) return true ;
if (confidentialKeywords. some ( k =>
email.subject. toLowerCase (). includes (k) || email.body. toLowerCase (). includes (k)
)) return true ;
return false ;
}
運用で気づいた、公式ドキュメントには載っていないこと
私自身、2014 年から個人開発を続ける中で「自分のための小さな自動化」を数えきれないほど作ってきました。Apps Script による Workspace 連携も例外ではなく、実際に数週間運用してみると、ドキュメントを読んだだけでは気づけない落とし穴がいくつもありました。ここでは、そのうち特に再現性が高く、運用初日に効いてくる 4 点を共有します。
1. 構造化出力でも JSON がコードフェンスで包まれて返ることがある
responseMimeType: 'application/json' を指定していても、gemini-2.5-flash はごくまれに ```json ... ``` のようなフェンス付きで返してくることがあります。私の環境では約 200 リクエストに 1 回程度の頻度でした。JSON.parse() がそこで落ちるため、パース前にフェンスを剥がす一手間を入れておくと事故が激減します。
function parseGeminiJson ( rawText ) {
// 先頭・末尾のコードフェンスを除去してから JSON.parse する
const cleaned = rawText
. replace ( / ^ \s * ```(?:json) ? \s * / i , '' )
. replace ( / \s * ``` \s *$ / i , '' )
. trim ();
return JSON . parse (cleaned);
}
callGeminiForTriage の return JSON.parse(text); を return parseGeminiJson(text); に置き換えるだけです。たった数行ですが、これを入れる前は週に 1〜2 回はパース失敗で 1 通分のトリアージが欠けていました。
2. 再実行で予定が二重登録される — 冪等性をどこかで担保する
このパイプラインで最初に痛い目を見たのが、Calendar への二重登録でした。トリガーが 8:00 と 12:00 の 2 回走るうえ、未読のままのメールは次回も対象になるため、同じ会議招集メールから予定が 2 つ、3 つと増えていきます。対策はシンプルで、トリアージ済みスレッドに専用ラベルを付け、検索クエリ側で除外することです。
// triageEmails の検索クエリを変更
const threads = GmailApp. search (
'is:unread -category:promotions -category:social -label:AI-Triage-Done' , 0 , 10
);
// applyTriageActions の末尾に追加
const doneLabel = GmailApp. getUserLabelByName ( 'AI-Triage-Done' )
|| GmailApp. createLabel ( 'AI-Triage-Done' );
thread. addLabel (doneLabel);
「未読かどうか」を処理済み判定に使わないのがコツです。ユーザーが手動でメールを未読に戻すこともあるため、処理状態は専用ラベルで明示的に管理するほうが安全でした。
3. 1 実行あたりの処理可能件数は思ったより少ない
実測すると、1 通あたりの処理時間は Gemini API のレスポンス待ち(1〜3 秒)+ レート制限用の Utilities.sleep(4000)(4 秒)+ Gmail/Calendar 操作(1〜2 秒)で、おおよそ 6〜9 秒 に落ち着きました。無料アカウントの 6 分制限から逆算すると、安全に処理できるのは 1 実行あたり 35〜50 通 が現実的な上限です。受信量が多い方は、triageEmailsWithTimeLimit のように時間監視で打ち切り、残りを次回に回す設計が必須になります。私はここを甘く見て、最初の週は何度も Exceeded maximum execution time で途中失敗させてしまいました。
4. DriveApp.searchFiles のクエリにユーザー由来文字列をそのまま埋めない
Phase 3 の検索クエリは Gemini に生成させていますが、生成されたキーワードにアポストロフィ(')が含まれると、fullText contains '...' のクエリ文字列が壊れて検索が無言で 0 件になります。エスケープを一枚噛ませておきます。
function safeFullTextQuery ( keyword ) {
// シングルクォートをエスケープし、Drive クエリの破損を防ぐ
const escaped = keyword. replace ( /'/ g , " \\ '" );
return `fullText contains '${ escaped }' and mimeType != 'application/vnd.google-apps.folder' and trashed = false` ;
}
公式サンプルはどれもハードコードされた検索語なので、この問題には触れられていません。生成 AI に検索クエリを作らせるパイプラインでは、ほぼ確実に踏むポイントだと思います。
運用チェックリスト
本番投入の前に、私は毎回この順序で確認するようにしています。
setApiKey を一度だけ実行し、getApiKey() が空でないことをログで確認する
triageEmails を手動実行し、ラベルが意図通り付くか・ドラフトが暴発しないかを数通で検証する
AI-Triage-Done ラベルでの除外が効いているか、同じメールを 2 回処理しないか確認する
createCalendarEvent を 1 件だけ通し、衝突時の代替時間提案が妥当か目視する
すべて通ったら setupTriggers を実行し、初回はトリガー実行ログを必ず見届ける
小さく動かして確かめてから自動化に委ねる。手を動かしながら一歩ずつ信頼を積み上げていくこの感覚は、宮大工だった祖父たちの「丁寧に作る」という姿勢に、どこか通じるものがあるように感じています。
まとめ
ここではGemini API と Google Apps Script を使って Gmail・Calendar・Drive を横断する全自動 AI ワークフローの構築方法を解説しました。Phase 1 のメールトリアージから Phase 3 の事前準備ノート生成まで、一気通貫のパイプラインを実装することで、毎日の定型作業を大幅に削減できます。
重要なポイントは、本番運用を見据えたトリガー設計・エラーハンドリング・クォータ管理です。Gemini API のレート制限に合わせた Utilities.sleep() の挿入、指数バックオフ付きリトライ、実行時間制限への対応は、安定稼働に欠かせない要素です。
さらに発展させたい方には、Gemini × Google Sheets Apps Script で構築する AI 自動レポート生成パイプラインでスプレッドシートとの連携パターンを、Gemini API × Google Calendar で AI スケジュール管理アシスタントを構築する実践ガイド でカレンダー連携の基礎を確認されることをおすすめします。
Workspace の自動化をさらに深く