← 記事一覧へ戻る

【保存版】Gmail返信をAIに任せたら、1日30分浮いた話 — 全コード公開

Gmail自動返信下書きツールをGoogle Apps Script + Claude APIで作る完全ガイド。登録した相手のメールだけを自動判定、画像・PDFの添付も読みます。コピペで動く全コード付き。


📢 2026年8月:中身を大きく作り直しました(v2) 「メールにラベルを手で付ける」方式をやめて、登録した相手のメールを自動で見分ける方式に変えています。あわせて画像・PDFの添付ファイルも読めるようになりました。 以前の手順で設定した方も、この記事のとおりに入れ替えれば移行できます🌱

仕事って、同じことの繰り返しで案外時間取られてませんか?

僕、会社員として働いてるんですけど、最近すごく思うんです。

「仕事って、同じことの繰り返しで案外時間取られてるな」って。

中でも一番モヤモヤしてたのが、メールの返信

毎日メールチェックして、似たような問い合わせに、似たような返信を書く。 内容は毎回ほぼ同じなのに、ゼロから書いてるから1通あたり5分は普通にかかる。

しかも相手がいることだから、後回しにもしづらい。 他の仕事に集中したいのに、メール来るたびに思考が中断されて…💦

「これ、なんとか自動化できないかな…」

ちょっと前まではこういうモヤモヤをどうしようもなかったんですけど、今はAIがありますから!

AIに「Gmailの返信を自動で下書きしてくれる仕組み作れない?」って聞いたら、なんと、できちゃいました

もうこれでメールの返信に時間を割かないで済むって思うと、他の仕事にも自然とやる気が湧いてきます笑

今回の記事では、あなたにも同じように無駄を減らして、少しでも早く帰れるように、僕が実際に使ってる方法をそのままお伝えしますね🌱


はじめまして、みやもんです

簡単に自己紹介。

僕は普通の会社員をやりながら、副業で**「AIで業務を楽にする仕組み作り」**をやってます。

  • Xでは @miyamon_ai として発信中
  • ブログ「定時ダッシュ研究所」で、AI×業務効率化のノウハウを公開
  • リベシティでも活動中

「特別なスキルはないけど、AIで仕事をどう楽にするか」を、自分で試して→記事にして、を繰り返してます。

今回のGmail自動返信ツールも、僕自身が毎日使ってる仕組みです。


ざっくりやってることは「メール → AIに見せる → 下書き保存」だけ

このツールがやってることは、すごくシンプルです。

あらかじめ登録しておいた相手からメールが届いたら、AIがその内容を読んで、適切な返信文を考えて、Gmailの下書きフォルダに保存してくれる。それだけ。

自動送信はしません。 必ず自分の目で下書きを確認してから、送信ボタンを押す形になります。 だから「AIが勝手に変な返信を送っちゃった!」みたいな事故は起きません。安心🌱

登録は、Googleスプレッドシートにメールアドレスを1行足すだけ。 「この人からのメールは下書きを作ってほしい」という相手だけを選んで使えます。

⚠️ 最初は「ラベル方式」でした(そしてやめました)

実はこのツール、最初のバージョンでは**「自動返信対象」というラベルを、メールに手で付ける**方式だったんです。

でも自分で毎日使ってみて、すぐ気づきました。

ラベルを付けるには、結局そのメールを開いて中身を読まないといけない。 それって、自分で返信を書き始めるのと、手間がほとんど変わらないんですよね💦

「時短のために作ったのに、時短になってない」 これが分かった時点で、相手のメールアドレスで自動判定する方式に作り替えました

いま公開しているのはこの改良版(v2)です。 自分で使ってみて初めて分かることって、やっぱりありますね🌱


必要なもの

必要なもの入手方法コスト
Gmailアカウント既存のものでOK無料
GAS (Google Apps Script)Googleアカウントがあれば使える無料
GoogleスプレッドシートGoogleアカウントがあれば使える無料
Anthropic API キーhttps://console.anthropic.com で発行$5〜(最小チャージ)

⚠️ Anthropic Console でクレジットカード登録が必要(最小$5チャージ)。月のAPI利用料はメール20通でも¥100未満なので、$5あれば数ヶ月持ちます。

会社のメールで使いたい人へ

「これ本業でも使えたら最高じゃん…」と思ったあなた。 必ず会社に確認してから使ってくださいね🙇‍♂️

AIにメール本文を送る都合上、情報の取り扱いルール(情報セキュリティ規定など)に引っかかる可能性があります。

ただ、もし会社の業務効率化として認められれば、会社のお金でAPI課金してもらえるかもしれません。 その方が個人の財布的にも嬉しいですよね笑✨

Anthropic API キーの取得方法(10分)

  1. https://console.anthropic.com/ にアクセス
  2. メール or Googleアカウントでサインアップ

Anthropic Console ログイン画面

  1. 左サイドバー → 「API Keys」→ 右上「Create Key」
  2. キー名(例:gmail-auto-reply)を入力 → Create
  3. ⚠️ 表示された sk-ant-xxxxx を即コピー(1度しか表示されない!)
  4. 左サイドバー → 「Billing」→ クレジットカード登録 → 最小$5チャージ

API Keys 一覧画面

⚠️ Claude Pro/Max(チャット用月額契約)とは別物です。API keyだけ単独でも契約できます。


セットアップ手順(20分)

Step 1:GASプロジェクト作成

https://script.google.com/ を開いて、「新しいプロジェクト」をクリック。

GASプロジェクト一覧

プロジェクト名を「Gmail 自動返信下書き」に変更しておくと管理しやすいです。

Step 2:コードを貼り付け

デフォルトの function myFunction() { ... } を全選択して削除し、**この記事の一番下にある「【コピペ用】Code.gs 全コード」**を全部コピペして保存(Cmd+S)。

新規プロジェクト初期画面

貼り付け後はこんな感じ:

Code.gs 貼り付け済み

💡 コードの中に「自分用に書き換える3カ所」があります。 個人で使う人はそのまま、会社で使う人は名乗りや署名を書き換えてください(詳しくは末尾コードのコメント参照)。

Step 3:宛先リスト(スプレッドシート)を作る

「どの相手からのメールに下書きを作るか」を管理する表を用意します。

  1. Googleスプレッドシートで新しいファイルを作る(ファイル名は「Gmail返信ツール設定」など何でもOK)
  2. 画面下のシートタブ名を、半角小文字で whitelist に変更(ここは名前が違うと動きません)
  3. 1行目に見出しを入力:
A列B列C列
送信元メールアドレスメモ有効/無効
  1. 2行目から、下書きを作ってほしい相手のアドレスを入れていきます:
A列B列C列
client@example.com○○商事さん有効
paused@example.com今は止めてる無効

ポイント

  • A列はメールアドレスだけ。「山田太郎 <yamada@example.com>」のような形では入れない
  • 大文字・小文字は区別されません
  • C列は空欄でも「有効」扱い。一時的に止めたいときだけ 無効 と書く(行を消さなくていいのが便利🌱)
  • あとから相手を増やすときは、この表に1行足すだけ。コードもトリガーも触りません

そして、ブラウザのURLからスプレッドシートのIDを控えます。

https://docs.google.com/spreadsheets/d/【この部分がID】/edit

Step 4:スクリプトプロパティを登録

左サイドバー → ⚙️「プロジェクトの設定」をクリック。

プロジェクトの設定メニュー

下の「スクリプト プロパティ」セクションで、3つ登録します。

プロパティ名説明
ANTHROPIC_API_KEYsk-ant-xxxxx前章でコピーしたキー
SPREADSHEET_IDStep 3で控えたID宛先リストの場所
TRIGGER_INTERVAL_MINUTES10何分ごとにチェックするか

スクリプトプロパティ追加画面

「スクリプト プロパティを保存」をクリック。

⚠️ APIキーとスプレッドシートIDは、コードの中に直接書かないでください。 必ずこのスクリプトプロパティに入れます。コードをそのまま人に見せても、鍵が漏れないようにするためです。

💡 TRIGGER_INTERVAL_MINUTES を入れないと60分ごとになります。指定できるのは 1 / 5 / 10 / 15 / 30 / 60 / 120 / 240 / 360 / 480 / 720 のいずれか。

Step 5:API動作テスト

エディタに戻って、関数選択ドロップダウンで testClaudeApi を選択 → ▶ 実行。

初回は権限承認が求められます:

  • 「権限を確認」→ Googleアカウント選択
  • 「詳細」→「(プロジェクト名)(安全ではないページ)に移動」→「許可」

実行ログに Claude API テスト Status: 200 と「テストOK」が出ればOK🎉

Step 6:自動実行をONにする

関数選択で setupTimeTrigger を選択 → ▶ 実行。1回だけでOKです。

実行ログに「main を 10 分ごとに実行するトリガーを作成しました。」と出れば完了🎉

左サイドバーの ⏰「トリガー」を開いて、main のトリガーが1件だけあることを確認しておくと安心です。

トリガー設定画面

💡 チェックの間隔を変えたくなったら、TRIGGER_INTERVAL_MINUTES を書き換えて、もう一度 setupTimeTrigger を実行します。古いトリガーは自動で消えて作り直されるので、二重に動く心配はありません。


プロンプト設計のコツ(核心ノウハウ)

このツールの返信の質を決めるのは、Claude(AI)に渡す「プロンプト(指示文)」です。

僕が使ってるプロンプトのポイントは4つ:

コツ①:トーンを具体的に

「丁寧に」だけだと堅くなる。「丁寧だが堅すぎない、親しみやすいトーン」と書くと、適度な距離感に。

コツ②:自分の名乗りを固定する

「お世話になっております。みやもんです。」と固定すると、毎回ブレない。

コツ③:不明な点は質問させる

「分からないことを推測で書く」を防ぐため、「不明な点があれば箇条書きで質問する形にする」と指示。

コツ④:余計な出力を消す

「返信本文のみを返す」を入れないと、AIが「以下が返信文です:」みたいな前置きを付ける。


動作確認・使い方

テスト用メールで試す

まず、Step 3で作った表のA列に、テスト用の送信元アドレスを1行足しておきます。

そのアドレスから自分宛にメールを送って、開かずに(未読のまま) 置いておきます。

GASエディタで main を手動実行。

実行ログに「処理対象スレッド数: 1」「下書き保存完了」「成功: 1件 / エラー: 0件」と出ればOK🎉

main 実行ログ

Gmail の「下書き」フォルダを開くと、AI が書いた返信が入っています:

生成された下書きメール

「お世話になっております。みやもんです。」始まりで、相手の問い合わせ内容(料金・納期)に対して逆質問で確認事項を聞く返信が生成されてます。

これ、人間が書いてもおかしくない品質です🌱

画像やPDFが添付されていても大丈夫

改良版(v2)から、添付ファイルの中身も読んで返信を考えるようになりました。

  • 対応:画像(PNG・JPEG・WebPなど)とPDF
  • サイズ:1ファイル10MBまで
  • WordやExcel、10MBを超えるファイルは読み飛ばして、本文だけで下書きを作ります(エラーで止まったりはしません)

「請求書のPDFが送られてきて、その中身を踏まえて返信したい」みたいなときに効いてきます。

日常運用

  1. 下書きを作ってほしい相手を、スプレッドシートに1行足しておく(最初の1回だけ)
  2. その相手からメールが届くと、設定した間隔(例:10分)で自動的に下書きができる
  3. Gmailの下書きを開いて、内容確認→必要なら修正→送信

ラベルを付ける手間がないので、気づいたら下書きができている状態になります🌱

💡 対象は「過去24時間以内に届いた未読メール」です。1回の実行で最大10通まで処理します。 同じスレッドにすでに返信の下書きがある場合は、二重に作らないようスキップされます。


注意点・運用ルール

気をつけたいこと

  • ⚠️ 会社のメールで使う前に、必ず会社に確認を取る(情報の取り扱いルールに引っかかる可能性あり)
  • ❌ 自動送信化(必ず人間チェック後に送信)
  • ❌ 重要顧客のメールへの即時返信(最終的な人間判断が大事)
  • ⚠️ 宛先リストのスプレッドシートには実際の取引先アドレスが並びます。 共有リンクを配ったり、GitHubなどに上げたりしないこと

やった方がいいこと

  • 宛先リストに載せる相手を慎重に選ぶ(最初は1〜2件から始めるのがおすすめ)
  • ✅ 止めたいときは行を消さず、C列を 無効 にする(あとで戻せる)
  • ✅ 月に1度、生成された返信の質を振り返る
  • ✅ プロンプトを自分のトーンに合わせて調整

コスト目安

  • 1メール処理:約 ¥1〜3
  • 月20通処理:¥100未満

カスタマイズ・拡張案

拡張① 相手ごとに返信のトーンを変える

  • 宛先リストのB列(メモ)に「取引先」「社内」などを書いておき、それを見てトーンを切り替える

拡張② 過去のやり取りを参照

  • 同じスレッドの過去メールも Claude に渡すと、文脈精度UP

拡張③ チェックの間隔を場面で変える

  • 忙しい時期は TRIGGER_INTERVAL_MINUTES5 に、落ち着いたら 60

まとめ:こんな会社員にお勧め

  • ✅ 似たような問い合わせメールを毎日返してる人
  • ✅ メール返信に1日30分以上使ってる人
  • ✅ 「AIで時短したいけど、何から始めればいいか分からない」人

このツール、僕の中で「定時で帰るための最強の相棒」になりました🌱


つまづいたら、気軽にDMください

ここまで読んでくれてありがとうございます🙇‍♂️

正直に言うと、コードを触ったことがない人は、途中で「あれ?スクショと画面が違う!」「ここのコードどうやって書き換えるの?」って詰まる場面が出てくると思います(僕も最初そうでした💦)。

そんな時は、Xで気軽にDMください👇

🐦 @miyamon_ai

「ここがわかりません!」「このスクショと違うんですけど…」みたいな質問も大歓迎です。 記事もみなさんの声を反映して、随時アップデートしていきますね🌱


【コピペ用】Code.gs 全コード

ここから下のコードを全部コピーして、Step 2 でGASエディタに貼り付けてください。

コードの上部に「自分用に書き換える3カ所」があるので、特に会社で使う方は要チェック⭐️

/**
 * Gmail 自動返信下書きツール v2
 *
 * スプレッドシートのホワイトリストに登録した送信元から届いた未読メールを対象に、
 * Claude が日本語の返信文を作成し、Gmail の下書きとして保存する。
 * 送信は自動化せず、下書きの確認と送信は利用者が行う。
 *
 * @author みやもん
 * @version 2.0.0
 */

// ===== 固定設定 =====
const WHITELIST_SHEET_NAME = 'whitelist';
const CLAUDE_MODEL = 'claude-opus-4-5';
const MAX_TOKENS = 1024;
const MAX_EMAILS_PER_RUN = 10;
const MAX_THREADS_TO_SCAN = 50;
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const DEFAULT_TRIGGER_INTERVAL_MINUTES = 60;
const MESSAGE_LOOKBACK_MILLISECONDS = 24 * 60 * 60 * 1000;

/**
 * メイン処理。時刻ベーストリガーから定期的に実行する。
 */
function main() {
  let lock;
  try {
    lock = LockService.getScriptLock();
    if (!lock.tryLock(1000)) {
      Logger.log('別の処理が実行中のため、今回の実行をスキップします。');
      return;
    }
  } catch (error) {
    Logger.log('エラー: 実行ロックを取得できませんでした: ' + error.message);
    return;
  }

  try {
    const apiKey = PropertiesService.getScriptProperties().getProperty('ANTHROPIC_API_KEY');
    if (!apiKey) {
      Logger.log('エラー: ANTHROPIC_API_KEY がスクリプトプロパティに設定されていません。');
      return;
    }

    let whitelist;
    try {
      whitelist = getWhitelist();
    } catch (error) {
      Logger.log('エラー: ホワイトリストを取得できませんでした: ' + error.message);
      return;
    }

    if (whitelist.length === 0) {
      Logger.log('有効なホワイトリスト登録がないため、処理を終了します。');
      return;
    }

    const query = buildSearchQuery(whitelist);
    const cutoffDate = new Date(Date.now() - MESSAGE_LOOKBACK_MILLISECONDS);
    let threads;
    let draftThreadIds;

    try {
      Logger.log('Gmail 検索クエリ: ' + query);
      threads = GmailApp.search(query, 0, MAX_THREADS_TO_SCAN);
      draftThreadIds = getDraftThreadIds();
    } catch (error) {
      Logger.log('エラー: Gmail の検索または下書き取得に失敗しました: ' + error.message);
      return;
    }

    let attemptedCount = 0;
    let successCount = 0;
    let skippedCount = 0;
    let errorCount = 0;

    // スレッドごとに最新の対象メールだけを処理し、1回の API 呼び出し数を抑える。
    for (let i = 0; i < threads.length; i++) {
      try {
        const thread = threads[i];
        const message = findLatestTargetMessage(thread, whitelist, cutoffDate);

        if (!message) {
          continue;
        }

        if (isDraftAlreadyCreated(thread, draftThreadIds)) {
          Logger.log('スキップ(下書き作成済み): ' + message.getSubject());
          skippedCount++;
          continue;
        }

        if (attemptedCount >= MAX_EMAILS_PER_RUN) {
          break;
        }

        attemptedCount++;
        processEmail(message);
        draftThreadIds[thread.getId()] = true;
        successCount++;
      } catch (error) {
        errorCount++;
        Logger.log('エラー: メール処理に失敗しました: ' + error.message);
      }
    }

    Logger.log(
      '完了 - 処理試行: ' + attemptedCount +
      '件 / 下書き作成: ' + successCount +
      '件 / スキップ: ' + skippedCount +
      '件 / エラー: ' + errorCount + '件'
    );
  } catch (error) {
    Logger.log('エラー: main の実行に失敗しました: ' + error.message);
  } finally {
    try {
      lock.releaseLock();
    } catch (error) {
      Logger.log('エラー: 実行ロックを解放できませんでした: ' + error.message);
    }
  }
}

/**
 * v1 の既存トリガーが残っていても v2 処理を実行できるようにする互換用関数。
 * setupTimeTrigger() を一度実行すると、旧トリガーは削除され main トリガーへ移行する。
 */
function autoDraftReplies() {
  Logger.log('旧関数 autoDraftReplies から v2 の main を実行します。');
  main();
}

/**
 * 設定スプレッドシートから有効な送信元メールアドレスを取得する。
 *
 * A列: 送信元メールアドレス
 * B列: メモ(任意)
 * C列: 有効/無効(任意。空欄または「有効」は有効として扱う)
 */
function getWhitelist() {
  const spreadsheetId = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');
  if (!spreadsheetId) {
    Logger.log('エラー: SPREADSHEET_ID がスクリプトプロパティに設定されていません。');
    throw new Error('SPREADSHEET_ID が未設定です。');
  }

  let sheet;
  try {
    const spreadsheet = SpreadsheetApp.openById(spreadsheetId);
    sheet = spreadsheet.getSheetByName(WHITELIST_SHEET_NAME);
  } catch (error) {
    Logger.log('エラー: スプレッドシートを開けませんでした: ' + error.message);
    throw error;
  }

  if (!sheet) {
    Logger.log('エラー: シート「' + WHITELIST_SHEET_NAME + '」が見つかりません。');
    throw new Error('シート「' + WHITELIST_SHEET_NAME + '」が見つかりません。');
  }

  const lastRow = sheet.getLastRow();
  if (lastRow === 0) {
    return [];
  }

  const rows = sheet.getRange(1, 1, lastRow, 3).getDisplayValues();
  const uniqueAddresses = {};
  const whitelist = [];

  rows.forEach(function(row, index) {
    const email = normalizeEmail(row[0]);
    const status = String(row[2] || '').trim().toLowerCase();

    if (!email) {
      return;
    }

    // 1行目に見出しを置く場合は、メールアドレスとして扱わない。
    if (index === 0 && isHeaderValue(email)) {
      return;
    }

    if (!isValidEmail(email)) {
      Logger.log('ホワイトリストの無効なメールアドレスをスキップします(行 ' + (index + 1) + '): ' + email);
      return;
    }

    if (isDisabledStatus(status)) {
      return;
    }

    if (!uniqueAddresses[email]) {
      uniqueAddresses[email] = true;
      whitelist.push(email);
    }
  });

  Logger.log('有効なホワイトリスト登録数: ' + whitelist.length + '件');
  return whitelist;
}

/**
 * 送信元メールアドレスがホワイトリストに完全一致するか確認する。
 */
function checkSenderInWhitelist(senderEmail, whitelist) {
  const normalizedSender = extractEmailAddress(senderEmail);
  if (!normalizedSender) {
    return false;
  }

  return whitelist.some(function(allowedEmail) {
    return normalizedSender === normalizeEmail(allowedEmail);
  });
}

/**
 * ホワイトリストを Gmail 検索クエリの from 条件へ変換する。
 */
function buildSearchQuery(whitelist) {
  if (!whitelist || whitelist.length === 0) {
    Logger.log('エラー: 検索クエリを作成するホワイトリストが空です。');
    throw new Error('ホワイトリストが空です。');
  }

  const fromFilters = whitelist.map(function(email) {
    return 'from:' + email;
  });

  if (fromFilters.length === 1) {
    return 'is:unread newer_than:1d ' + fromFilters[0];
  }

  return 'is:unread newer_than:1d {' + fromFilters.join(' ') + '}';
}

/**
 * スレッド内から、実際に v2 の対象条件を満たす最新のメールを探す。
 * Gmail 検索はスレッド単位で返るため、メール単位でも条件を再確認する。
 */
function findLatestTargetMessage(thread, whitelist, cutoffDate) {
  const messages = thread.getMessages();

  for (let i = messages.length - 1; i >= 0; i--) {
    const message = messages[i];
    if (
      message.isUnread() &&
      message.getDate().getTime() >= cutoffDate.getTime() &&
      checkSenderInWhitelist(message.getFrom(), whitelist)
    ) {
      return message;
    }
  }

  return null;
}

/**
 * 既存下書きのスレッド ID 一覧を作り、同じスレッドへの重複下書きを防ぐ。
 */
function getDraftThreadIds() {
  const draftThreadIds = {};
  const drafts = GmailApp.getDrafts();

  drafts.forEach(function(draft) {
    try {
      draftThreadIds[draft.getMessage().getThread().getId()] = true;
    } catch (error) {
      Logger.log('エラー: 下書きのスレッド確認に失敗しました: ' + error.message);
    }
  });

  return draftThreadIds;
}

/**
 * 対象スレッドに返信下書きが既に存在するか確認する。
 */
function isDraftAlreadyCreated(thread, draftThreadIds) {
  const threadId = thread.getId();
  if (draftThreadIds) {
    return Boolean(draftThreadIds[threadId]);
  }

  return Boolean(getDraftThreadIds()[threadId]);
}

/**
 * 1通のメールについて、添付取得、返信生成、下書き保存を行う。
 */
function processEmail(message) {
  const attachments = getAttachments(message);
  const replyText = callClaudeAPI(message, attachments);

  if (!replyText) {
    throw new Error('Claude から返信本文を取得できませんでした。');
  }

  message.createDraftReply(replyText);
  Logger.log('下書き保存完了: ' + message.getSubject() + '(添付利用: ' + attachments.length + '件)');
}

/**
 * Gmail メッセージから Claude に渡せる画像または PDF の添付のみを取得する。
 * 署名画像などのインライン画像は対象から外す。
 *
 * 注意:GAS の getAttachments() に includeAttachments:true を明示指定すると
 * 0件になる謎挙動がある(2026-05-24 検証済み)。
 * オプションは includeInlineImages のみ指定すること。
 */
function getAttachments(message) {
  const blobs = message.getAttachments({
    includeInlineImages: false,
  });
  Logger.log('添付検出: ' + blobs.length + '件(メール: ' + message.getSubject() + ')');

  const acceptedAttachments = [];

  blobs.forEach(function(blob) {
    const fileName = blob.getName() || '名称なし添付ファイル';
    const mediaType = normalizeMediaType(blob.getContentType());
    const bytes = blob.getBytes();
    const size = bytes.length;

    if (!isSupportedAttachmentType(mediaType)) {
      Logger.log('添付をスキップ(未対応形式): ' + fileName + ' / ' + mediaType);
      return;
    }

    if (size > MAX_ATTACHMENT_BYTES) {
      Logger.log('添付をスキップ(10MB超過): ' + fileName + ' / ' + size + ' bytes');
      return;
    }

    acceptedAttachments.push({
      name: fileName,
      mediaType: mediaType,
      data: Utilities.base64Encode(bytes),
    });
  });

  return acceptedAttachments;
}

/**
 * メール本文と対応添付を Claude Messages API に渡して返信文を生成する。
 */
function callClaudeAPI(message, attachments) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('ANTHROPIC_API_KEY');
  if (!apiKey) {
    Logger.log('エラー: ANTHROPIC_API_KEY がスクリプトプロパティに設定されていません。');
    throw new Error('ANTHROPIC_API_KEY が未設定です。');
  }

  const content = [{
    type: 'text',
    text: buildReplyPrompt(message, attachments),
  }];

  attachments.forEach(function(attachment) {
    content.push({
      type: attachment.mediaType === 'application/pdf' ? 'document' : 'image',
      source: {
        type: 'base64',
        media_type: attachment.mediaType,
        data: attachment.data,
      },
    });
  });

  let response;
  try {
    response = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
      method: 'post',
      contentType: 'application/json',
      muteHttpExceptions: true,
      headers: {
        'x-api-key': apiKey,
        'anthropic-version': '2023-06-01',
      },
      payload: JSON.stringify({
        model: CLAUDE_MODEL,
        max_tokens: MAX_TOKENS,
        messages: [{
          role: 'user',
          content: content,
        }],
      }),
    });
  } catch (error) {
    Logger.log('エラー: Claude API の呼び出しに失敗しました: ' + error.message);
    throw error;
  }

  const statusCode = response.getResponseCode();
  const responseText = response.getContentText();
  if (statusCode !== 200) {
    Logger.log('エラー: Claude API が失敗しました(HTTP ' + statusCode + '): ' + responseText);
    throw new Error('Claude API が HTTP ' + statusCode + ' を返しました。');
  }

  let data;
  try {
    data = JSON.parse(responseText);
  } catch (error) {
    Logger.log('エラー: Claude API レスポンスの JSON 解析に失敗しました: ' + error.message);
    throw error;
  }

  const textBlock = (data.content || []).filter(function(block) {
    return block.type === 'text' && block.text;
  })[0];

  if (!textBlock) {
    Logger.log('エラー: Claude API レスポンスに返信本文がありません。');
    throw new Error('Claude API レスポンス形式が想定外です。');
  }

  return textBlock.text.trim();
}

/**
 * Claude に渡す日本語の返信作成指示を組み立てる。
 */
function buildReplyPrompt(message, attachments) {
  const subject = message.getSubject();
  const body = message.getPlainBody().slice(0, 3000);
  const from = message.getFrom();
  let attachmentList = 'なし';

  if (attachments.length > 0) {
    attachmentList = attachments.map(function(attachment) {
      return '- ' + attachment.name + ' (' + attachment.mediaType + ')';
    }).join('\n');
  }

  return '以下のメールに対する、自然で丁寧な返信文を日本語で書いてください。\n\n' +
    '【送信者】' + from + '\n' +
    '【件名】' + subject + '\n' +
    '【本文】\n' + body + '\n\n' +
    '【確認できる添付ファイル】\n' + attachmentList + '\n\n' +
    '【返信のルール】\n' +
    '- 冒頭:「お世話になっております。みやもんです。」\n' +
    '- 丁寧だが堅すぎない、親しみやすいトーン\n' +
    '- 相手の問い合わせ内容に対して具体的に回答する\n' +
    '- 添付ファイルがある場合は、その内容も踏まえて返答する\n' +
    '- 内容が不明確な場合は、確認したい点を箇条書きで質問する\n' +
    '- 末尾は状況に応じた自然なクロージングにする\n' +
    '- 署名は付けない(みやもんが後で追加する)\n' +
    '- 返信本文のみを返す(説明や前置きは不要)';
}

/**
 * 設定値に基づき main を定期実行する時刻ベーストリガーを作る。
 * GAS が許可する間隔のみ使用できる。
 */
function setupTimeTrigger() {
  try {
    const properties = PropertiesService.getScriptProperties();
    const configuredValue = properties.getProperty('TRIGGER_INTERVAL_MINUTES');
    const intervalMinutes = configuredValue ? Number(configuredValue) : DEFAULT_TRIGGER_INTERVAL_MINUTES;

    if (!isSupportedTriggerInterval(intervalMinutes)) {
      throw new Error('TRIGGER_INTERVAL_MINUTES は 1, 5, 10, 15, 30, 60, 120, 240, 360, 480, 720 のいずれかに設定してください。');
    }

    // 既存 v1 トリガーと v2 トリガーを消して、重複実行を避ける。
    ScriptApp.getProjectTriggers().forEach(function(trigger) {
      const handler = trigger.getHandlerFunction();
      if (handler === 'main' || handler === 'autoDraftReplies') {
        ScriptApp.deleteTrigger(trigger);
        Logger.log('既存トリガーを削除しました: ' + handler);
      }
    });

    const triggerBuilder = ScriptApp.newTrigger('main').timeBased();
    if (intervalMinutes < 60) {
      triggerBuilder.everyMinutes(intervalMinutes).create();
    } else {
      triggerBuilder.everyHours(intervalMinutes / 60).create();
    }

    Logger.log('main を ' + intervalMinutes + ' 分ごとに実行するトリガーを作成しました。');
  } catch (error) {
    Logger.log('エラー: 時刻ベーストリガーを設定できませんでした: ' + error.message);
    throw error;
  }
}

/**
 * テスト用: API キーと Claude API 接続だけを確認する。
 */
function testClaudeApi() {
  const apiKey = PropertiesService.getScriptProperties().getProperty('ANTHROPIC_API_KEY');
  if (!apiKey) {
    Logger.log('エラー: ANTHROPIC_API_KEY が設定されていません。');
    return;
  }

  try {
    const response = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
      method: 'post',
      contentType: 'application/json',
      muteHttpExceptions: true,
      headers: {
        'x-api-key': apiKey,
        'anthropic-version': '2023-06-01',
      },
      payload: JSON.stringify({
        model: CLAUDE_MODEL,
        max_tokens: 50,
        messages: [{
          role: 'user',
          content: 'こんにちは。「テストOK」とだけ返してください。',
        }],
      }),
    });

    Logger.log('Claude API テスト Status: ' + response.getResponseCode());
    Logger.log('Claude API テスト Response: ' + response.getContentText());
  } catch (error) {
    Logger.log('エラー: Claude API テストに失敗しました: ' + error.message);
  }
}

/**
 * 送信元表記から比較用の小文字メールアドレスを取り出す。
 */
function extractEmailAddress(senderValue) {
  const text = String(senderValue || '').trim();
  const bracketMatch = text.match(/<([^<>]+)>/);
  const source = bracketMatch ? bracketMatch[1] : text;
  const emailMatch = source.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
  return emailMatch ? normalizeEmail(emailMatch[0]) : '';
}

/**
 * メールアドレス比較用の正規化を行う。
 */
function normalizeEmail(email) {
  return String(email || '').trim().toLowerCase();
}

/**
 * ホワイトリストに検索条件として利用できるアドレスだけを許可する。
 */
function isValidEmail(email) {
  return /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email);
}

/**
 * 任意の見出し行を認識する。
 */
function isHeaderValue(value) {
  return [
    'email',
    'email address',
    'メールアドレス',
    '送信元メールアドレス',
  ].indexOf(value) !== -1;
}

/**
 * C列が明示的に無効を示す場合だけ処理対象外にする。
 */
function isDisabledStatus(status) {
  return [
    '無効',
    'inactive',
    'disabled',
    'false',
    'off',
    '0',
  ].indexOf(status) !== -1;
}

/**
 * 添付の MIME タイプを比較用に整える。
 */
function normalizeMediaType(mediaType) {
  return String(mediaType || '').split(';')[0].trim().toLowerCase();
}

/**
 * Claude へ送信可能な画像または PDF か判定する。
 */
function isSupportedAttachmentType(mediaType) {
  return mediaType.indexOf('image/') === 0 || mediaType === 'application/pdf';
}

/**
 * GAS の時刻ベーストリガーで指定可能な分間隔か判定する。
 */
function isSupportedTriggerInterval(intervalMinutes) {
  return [
    1,
    5,
    10,
    15,
    30,
    60,
    120,
    240,
    360,
    480,
    720,
  ].indexOf(intervalMinutes) !== -1;
}

自分用に書き換える3カ所まとめ

場所何を書く?
MY_OPENING返信の冒頭挨拶(個人なら「みやもんです」、会社なら「株式会社〇〇の…」)
MY_SIGNATURE末尾の署名(不要なら空っぽでOK)
MY_TONE返信のトーン(堅い・カジュアル・親しみやすいなど)

特に会社で使う人は、MY_SIGNATURE に会社の正式署名(社名・部署・電話番号など)を入れておけば、AIが書いた返信の末尾に毎回自動で署名が付きます🌱


関連リンク