GASでメール本文からAIで予定を抽出し
Googleカレンダーに自動登録する方法

「来週火曜の15時に打ち合わせでお願いします」——こうしたメールを、手でカレンダーに転記していませんか。 GmailApp で本文を取り出し、Claude API(Anthropic社のAI)に渡せば、日時・場所・件名を読み取ってCalendarAppでGoogleカレンダーに自動登録できます。動くコードで手順を解説します。

|対象: GmailApp / Claude API / CalendarApp / 定期トリガー

Table of Contents

このツールで自動化できること

取引先やお客様から届く「日程調整メール」には、打ち合わせの日時・場所が自然な日本語で書かれています。 人が読めば分かりますが、書き方はメールごとにバラバラです。こうした「揺れる文章」から予定を取り出すのは、 正規表現(文字列のパターン照合)よりもAIが得意な領域です。

Before / After

これまで

メールを開いて日時を読み取り、カレンダーを開いて手入力。転記ミスや登録漏れも起きる。

このツール

30分ごとに未処理メールをAIが読み、予定があれば自動でカレンダーに登録。無ければ何もしない。

この記事では、Gmail検索Claude APIでの抽出カレンダー登録二重登録防止定期実行までを、 そのまま動くコードで作ります。

全体の処理の流れ

仕組みはシンプルです。「未処理メールを探す → AIで予定を抜く → カレンダーに入れる → 処理済みにする」を、 トリガー(時間で自動実行する仕掛け)で回すだけです。

[1] Gmailを検索
      未処理メール(-label:AI予定登録済み)を取得
        ↓
[2] 本文をClaude APIに渡す
      日時・場所・件名をJSONで抽出(受信日を基準に絶対日付へ)
        ↓
[3] 予定があればGoogleカレンダーに登録
      メールIDを説明欄に記録して二重登録を防止
        ↓
[4] 処理済みラベルを付ける
      次回以降は検索対象から外す
        ↓
[5] 時間主導トリガーで定期実行

事前準備は2つだけです。1つはClaude APIのAPIキーの取得、もう1つはそのキーをPropertiesService(設定値を安全に保存する機能)に登録することです。キーをコードに直接書かないのが安全です。

Gmailから未処理メールを取得する

まず土台となる関数です。GmailApp.search()で未処理メールを取得します。検索クエリに-label:AI予定登録済みを入れて、まだ処理していないメールだけに絞るのがポイントです。

// スクリプトプロパティに API キーを保存しておく
// (ファイル > プロジェクトのプロパティ ではなく、
//  プロジェクト設定 > スクリプト プロパティ に ANTHROPIC_API_KEY を登録)
const API_KEY = PropertiesService
  .getScriptProperties()
  .getProperty('ANTHROPIC_API_KEY');

const PROCESSED_LABEL = 'AI予定登録済み';
const CALENDAR_ID = 'primary'; // 別カレンダーなら そのカレンダーID

function registerEventsFromMail() {
  const label = getOrCreateLabel_(PROCESSED_LABEL);

  // 未処理・受信トレイのメールだけを対象にする(最大20スレッド)
  const query = 'in:inbox -label:' + PROCESSED_LABEL + ' newer_than:14d';
  const threads = GmailApp.search(query, 0, 20);

  threads.forEach(function(thread) {
    const message = thread.getMessages()[0]; // 先頭メッセージだけ見る
    const receivedAt = message.getDate();
    const body = message.getPlainBody().slice(0, 4000); // 長文は切り詰め

    const events = extractEvents_(body, receivedAt);
    events.forEach(function(ev) {
      createCalendarEvent_(ev, message.getId());
    });

    thread.addLabel(label); // 処理済みにする
  });
}

// ラベルが無ければ作って返す
function getOrCreateLabel_(name) {
  return GmailApp.getUserLabelByName(name)
    || GmailApp.createLabel(name);
}

ポイント

本文はgetPlainBody()でHTMLタグを除いたテキストを取り、slice(0, 4000)で長すぎる本文を切り詰めます。全文をAIに送るとトークン(AIが文章を数える単位)と料金が増えるため、 予定が書かれる冒頭〜中盤だけで十分なケースが多いです。

Claude APIで予定を抽出する

ここが核心です。本文をClaude APIに渡し、予定をeventsという配列のJSONで返させます。「明日」「来週火曜」を正しく変換できるよう、 プロンプトに受信日(基準日)を必ず入れます。

// メール本文から予定を抽出する(Claude API)
function extractEvents_(body, receivedAt) {
  const baseDate = Utilities.formatDate(
    receivedAt, 'Asia/Tokyo', 'yyyy-MM-dd (EEE)'
  );

  const prompt =
    'あなたは日本語のビジネスメールから打ち合わせ予定を抽出する担当です。\n' +
    'このメールの受信日は ' + baseDate + ' です。\n' +
    '「明日」「来週火曜」などの相対表現は、受信日を基準に西暦の日付へ変換してください。\n' +
    '本文に予定が無ければ events を空配列にしてください。\n' +
    '日付や開始時刻が特定できない予定は date または start を null にしてください。\n\n' +
    '次のJSONだけを返してください(前後に説明文を付けない):\n' +
    '{"events":[{"title":"件名","date":"YYYY-MM-DD",' +
    '"start":"HH:mm","end":"HH:mm","location":"場所",' +
    '"confidence":0-100}]}\n\n' +
    '--- メール本文 ---\n' + body;

  const payload = {
    model: 'claude-sonnet-5',
    max_tokens: 1024,
    messages: [{ role: 'user', content: prompt }]
  };

  const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
    method: 'post',
    contentType: 'application/json',
    headers: {
      'x-api-key': API_KEY,
      'anthropic-version': '2023-06-01'
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });

  if (res.getResponseCode() !== 200) {
    console.error('API エラー: ' + res.getContentText());
    return [];
  }

  // 回答テキスト → JSON。前後にゴミが付いても { } の範囲だけ取り出す
  const text = JSON.parse(res.getContentText()).content[0].text;
  const json = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1);

  try {
    const parsed = JSON.parse(json);
    return Array.isArray(parsed.events) ? parsed.events : [];
  } catch (e) {
    console.error('JSON解析に失敗: ' + text);
    return [];
  }
}

JSONを安全に取り出す

AIが前後に説明文を付けることがあるため、indexOf('{')lastIndexOf('}'){ }の範囲だけを取り出してからJSON.parseしています。より確実にJSON構造を固定したい場合は、Structured Outputs(構造化出力)を使う方法もあります。

抽出した予定をカレンダーに登録する

抽出した予定をCalendarAppで登録します。日付の文字列は+09:00(日本時間)を付けてDateに変換します。これを付け忘れると9時間ずれるので注意します。

// 抽出した予定をGoogleカレンダーに登録する
function createCalendarEvent_(ev, messageId) {
  // 日付が取れない・確信度が低い予定はスキップ(誤登録を防ぐ)
  if (!ev.date || (ev.confidence != null && ev.confidence < 60)) {
    console.log('スキップ: ' + JSON.stringify(ev));
    return;
  }

  const cal = CalendarApp.getCalendarById(CALENDAR_ID)
    || CalendarApp.getDefaultCalendar();

  // 二重登録チェック:同じメールIDの予定が既にあれば作らない
  const tag = 'mailId=' + messageId;
  const start = ev.start
    ? new Date(ev.date + 'T' + ev.start + ':00+09:00')
    : null;

  if (start) {
    const dayEvents = cal.getEventsForDay(start);
    const dup = dayEvents.some(function(e) {
      return (e.getDescription() || '').indexOf(tag) !== -1;
    });
    if (dup) {
      console.log('二重登録を防止: ' + ev.title);
      return;
    }
  }

  const options = {
    location: ev.location || '',
    description: '(AIがメールから自動登録)\n' + tag
  };

  if (start && ev.end) {
    // 開始・終了が揃っている → 時間指定の予定
    const end = new Date(ev.date + 'T' + ev.end + ':00+09:00');
    cal.createEvent(ev.title, start, end, options);
  } else if (start) {
    // 終了が無ければ1時間の予定にする
    const end = new Date(start.getTime() + 60 * 60 * 1000);
    cal.createEvent(ev.title, start, end, options);
  } else {
    // 時刻が取れない → 終日予定として登録
    cal.createAllDayEvent(ev.title, new Date(ev.date + 'T00:00:00+09:00'), options);
  }

  console.log('登録: ' + ev.date + ' ' + ev.title);
}

時刻あり

開始・終了で時間指定の予定を作成

終了時刻なし

開始から1時間の予定として作成

時刻が取れない

createAllDayEventで終日予定に

二重登録を防ぐ・定期実行する

自動化で一番怖いのは、同じ予定が何度も登録されることです。この記事では2段構えで防いでいます。

01処理済みメールにラベルを付け、検索クエリで対象から外す(同じメールを二度読まない)
02登録時にイベントの説明欄へ mailId=... を記録し、同じIDの予定があれば作らない(万一メールを再処理しても重複しない)

最後に、時間主導トリガーで定期実行します。同名トリガーを一度削除してから作り直すのが、 トリガーが増殖しない鉄則です。

// この関数を1回だけ手動実行すると、以後30分ごとに自動実行される
function setupTrigger() {
  // 二重登録を防ぐため、同名トリガーを一度すべて削除してから作り直す
  ScriptApp.getProjectTriggers().forEach(function(t) {
    if (t.getHandlerFunction() === 'registerEventsFromMail') {
      ScriptApp.deleteTrigger(t);
    }
  });

  ScriptApp.newTrigger('registerEventsFromMail')
    .timeBased()
    .everyMinutes(30)
    .create();
}

実務での注意点

1. まずは確認用カレンダーで慣らす

いきなり本カレンダーへ登録せず、テスト用の別カレンダー(CALENDAR_IDを差し替え)に数日流し、AIの読み取り精度を確かめてから本番に切り替えると安全です。

2. 確信度の低い予定は人が確認する

コードではconfidenceが60未満の予定をスキップしています。しきい値は運用に合わせて調整し、 スキップ分はスプレッドシートに書き出して人が確認する運用にすると、取りこぼしと誤登録の両方を抑えられます。

3. タイムゾーンと料金・実行時間

日付は必ずAsia/Tokyo基準で扱います。また、1回の実行で処理するスレッド数(この例では20件)を絞り、 GASの6分の実行時間制限とAPI料金の増えすぎを防ぎます。メール件数が多い場合は、 前回処理した位置を記録して分割実行する設計にします。

まとめ

GmailApp・Claude API・CalendarApp を組み合わせると、日程調整メールの転記作業をまるごと自動化できます。 肝は、受信日を基準にAIへ相対日付を変換させることと、ラベルとメールIDで二重登録を防ぐことの2点です。

この仕組みの応用先

面接日程の自動登録
納品・支払期日の管理
問い合わせ対応の期限化
イベント告知の予定化

よくある質問

定型的なメール(システムから届く予約確認メールなど)であれば、正規表現で日時を抜き出す方法でも対応できます。ただし、人が書いた「来週火曜の15時から」「明日の午後イチで」といった曖昧な表現や、フォーマットがバラバラなメールは正規表現だと破綻しがちです。Claude APIに本文をそのまま渡せば、文脈から日時・場所・件名をまとめて読み取れるため、書き方が揺れるメールに強くなります。

できます。プロンプトにメールの受信日(基準日)を明記し、その日を起点に絶対日付(yyyy-MM-dd)へ変換するようAIに指示します。曖昧で日付を特定できない場合は、無理に推測させず「dateがnull」で返させ、その予定はスキップして人が確認する運用にすると誤登録を防げます。

本文に予定が含まれるかどうかもAIに判定させます。予定が無ければ空の配列を返させ、GAS側で件数が0件なら登録処理をスキップします。加えて、処理済みのメールにはGmailラベルを付け、次回以降は検索対象から外すことで、同じメールを何度も処理しない設計にします。

2段階で防ぎます。1つ目は処理済みラベル(例: AI予定登録済み)を付け、search()のクエリで -label:AI予定登録済み を指定して未処理メールだけを対象にする方法です。2つ目はカレンダー登録時にイベントの説明欄へメールのID(getId())を書き込み、同じIDの予定が既にあれば登録しないチェックを入れる方法です。

いきなり本番カレンダーへ登録せず、まずは確認用の別カレンダーやスプレッドシートに書き出し、内容を目視してから本カレンダーへ移す運用がおすすめです。また、抽出結果の確信度(confidence)もAIに返させ、低いものだけ人が確認する「例外だけ手動」の運用にすると、精度と手間のバランスが取りやすくなります。

メール処理・AI連携の
自動化を相談する。

Gmail・カレンダー・スプレッドシートをAIでつなぐ業務自動化のご相談を承っています。 「この作業を自動化したい」という段階からで大丈夫です。現在の業務をお聞きした上でご提案します。

AI連携GAS開発を見る