1. はじめに
「Gmailに届いた日程調整や予約メールをGoogleカレンダーに自動登録したい」というニーズは多いですが、いきなり全自動でカレンダーに入れると以下のようなトラブルが起こりがちです。
- 本文の日時を誤認して変な日時に予定が入ってしまう
- カレンダーの重複や予定被りに気づけない
- 不要な案内メールや変更前の古い予定まで登録されてしまう
そこで本記事では、「メールから抽出した予定を一度スプレッドシートに下書きとして蓄積し、人間が確認してチェックを入れた瞬間にカレンダーへ反映&履歴シートへ自動移動する」という、安全かつ実用的な半自動ワークフローの構築手順を解説します。
2. システム構成とワークフロー
Plaintext[ Gmailにメール受信 ]
│
▼ ①【自動】15分おきにGmailを巡回(時間主導型トリガー)
[ 本文から日時・場所・件名を抽出してスプレッドシートに追記 ]
│
▼ ②【人間】シートを見て日時・タイトルを確認(必要なら手動修正)
│
├─・カレンダーに入れたい場合 👉 A列の「チェックボックス」をON
│ ▼ ③-A【自動】カレンダーへ登録 & 「履歴」シートへ自動移動
│
└─・入れたくない場合 👉 B列のステータスを「スキップ」に変更
▼ ③-B【自動】カレンダー登録せず 「履歴」シートへ自動移動
この設計のメリット
- 日時の誤認識を人間がリカバーできる: 正規表現で取得漏れがあっても、シート上で手修正してチェックすれば正常登録可能。
- 作業シートが常にスッキリ: 登録済み・スキップ済みの行は自動で「履歴」シートへ退避されるため、行数制限やスクロールの手間がない。
- 安全な二重登録防止: メッセージID管理とGmailラベル付与の2重ガード。
3. スプレッドシートの準備
3.1. 「シート1(確認用)」の作成
新規スプレッドシートを作成し、シート名を シート1 にします。
1行目(ヘッダー)に以下の項目を設定します。
| A列 | B列 | C列 | D列 | E列 | F列 | G列 | H列 |
|---|---|---|---|---|---|---|---|
| 登録チェック | ステータス | タイトル | 日付 | 開始時刻 | 終了時刻 | 場所 | メールID |
B列にプルダウン(データの入力規則)を設定:
B2セル以降を選択 > メニューの 「挿入」 > 「プルダウン」 を選択。
以下の選択肢を追加します:
- 未確認
- スキップ
- 不要
- 要日時修正
- 登録完了
3.2. 「履歴」シートの作成
左下の「+」ボタンでシートを追加し、シート名を 履歴 に変更します。
1行目(A1:H1)に シート1 と全く同じ見出しをコピー&ペーストしておきます。
4. 全コード(GAS)
スプレッドシートの 「拡張機能」 > 「Apps Script」 を開き、コードエディタに以下を貼り付けます。
JavaScript/**
/**
* =========================================================================
* 【処理1】スプレッドシート編集時の処理(インストーラブルトリガー:編集時)
* - A列のチェックON -> Googleカレンダーへ登録して「履歴」シートへ自動移動
* - B列のステータスが「スキップ」または「不要」 -> 登録せず「履歴」シートへ自動移動
* =========================================================================
*/
function handleCheckboxEdit(e) {
if (!e || !e.range) return;
const range = e.range;
const sheet = range.getSheet();
// 対象シートの限定(「シート1」以外は無視)
if (sheet.getName() !== 'シート1') return;
const col = range.getColumn();
const row = range.getRow();
if (row < 2) return; // 見出し行は無視
const value = String(e.value).trim();
const statusCell = sheet.getRange(row, 2);
const currentStatus = String(statusCell.getValue()).trim();
// --------------------------------------------------
// パターンA: A列のチェックボックスがONになった場合(本登録)
// --------------------------------------------------
if (col === 1 && value === 'TRUE') {
if (currentStatus === '登録完了') return;
// 行データ取得 [A:チェック, B:ステータス, C:タイトル, D:日付, E:開始, F:終了, G:場所, H:メールID]
const rowValues = sheet.getRange(row, 1, 1, 8).getValues()[0];
const title = rowValues[2];
const dateVal = rowValues[3];
const startVal = rowValues[4];
const endVal = rowValues[5];
const location = rowValues[6];
if (!dateVal || !startVal || !endVal) {
statusCell.setValue('エラー: 日時未入力');
return;
}
try {
// 日付と時刻を安全に結合してDateオブジェクトを生成
const startDateTime = combineDateAndTime(dateVal, startVal);
const endDateTime = combineDateAndTime(dateVal, endVal);
if (!startDateTime || !endDateTime || isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
statusCell.setValue('エラー: 日時形式不正');
return;
}
// デフォルトカレンダーへ登録
const calendar = CalendarApp.getDefaultCalendar();
calendar.createEvent(title, startDateTime, endDateTime, {
location: location
});
// ステータスを更新して「履歴」シートへ移動
statusCell.setValue('登録完了');
moveToHistorySheet(sheet, row);
} catch (err) {
statusCell.setValue('登録失敗: ' + err.message);
}
return;
}
// --------------------------------------------------
// パターンB: B列が「スキップ」または「不要」に変更された場合(登録せず退避)
// --------------------------------------------------
if (col === 2 && (value === 'スキップ' || value === '不要')) {
moveToHistorySheet(sheet, row);
}
}
/**
* 対象行を「履歴」シートへ移動し、元の「シート1」から削除する関数
*/
function moveToHistorySheet(sourceSheet, rowNumber) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let historySheet = ss.getSheetByName('履歴');
// 「履歴」シートが存在しない場合は自動作成
if (!historySheet) {
historySheet = ss.insertSheet('履歴');
historySheet.appendRow(['登録チェック', 'ステータス', 'タイトル', '日付', '開始時刻', '終了時刻', '場所', 'メールID']);
}
// 行データを取得
const rowData = sourceSheet.getRange(rowNumber, 1, 1, 8).getValues();
// 履歴シートの末尾に追加
historySheet.appendRow(rowData[0]);
// 元シートから行を削除して整理
sourceSheet.deleteRow(rowNumber);
}
/**
* スプレッドシートの日付(セルD)と時刻(セルE/F)を安全に結合する関数
*/
function combineDateAndTime(dateVal, timeVal) {
const timeZone = Session.getScriptTimeZone();
let dateStr = '';
let timeStr = '';
// 日付の正規化 (yyyy/MM/dd)
if (dateVal instanceof Date) {
dateStr = Utilities.formatDate(dateVal, timeZone, 'yyyy/MM/dd');
} else {
dateStr = String(dateVal).trim();
}
// 時刻の正規化 (HH:mm)
if (timeVal instanceof Date) {
timeStr = Utilities.formatDate(timeVal, timeZone, 'HH:mm');
} else {
timeStr = String(timeVal).trim();
}
return new Date(`${dateStr} ${timeStr}`);
}
/**
* =========================================================================
* 【処理2】定期巡回用: Gmailからメールを検索してシートに追記する処理
* (時間主導型トリガー:15分おきなど)
* =========================================================================
*/
function fetchEmailsToSheet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName('シート1') || ss.getActiveSheet();
// 【検索クエリ】直近3日以内・未処理・未読
const query = 'subject:{(面談) (面接) (予約) (打合せ) (打ち合わせ) (ミーティング) (セミナー)} -label:シート転記済み is:unread newer_than:3d';
const threads = GmailApp.search(query, 0, 10);
if (threads.length === 0) {
Logger.log('処理対象のメールはありません');
return;
}
// 処理済みラベルの取得または作成
let processedLabel = GmailApp.getUserLabelByName('シート転記済み');
if (!processedLabel) {
processedLabel = GmailApp.createLabel('シート転記済み');
}
// 二重登録防止: 「シート1」と「履歴」の両方から既に処理したメールIDを収集
let existingIds = [];
const getIdsFrom = (s) => {
if (s && s.getLastRow() >= 2) {
return s.getRange(2, 8, s.getLastRow() - 1, 1).getValues().flat().filter(String);
}
return [];
};
existingIds = existingIds.concat(getIdsFrom(sheet));
existingIds = existingIds.concat(getIdsFrom(ss.getSheetByName('履歴')));
// 【ドロップダウンの定義】追記時にB列へ自動適用するルール
const statusRule = SpreadsheetApp.newDataValidation()
.requireValueInList(['未確認', 'スキップ', '不要', '要日時修正', '登録完了'], true)
.setAllowInvalid(false)
.build();
threads.forEach(thread => {
const messages = thread.getMessages();
messages.forEach(message => {
const msgId = message.getId();
if (existingIds.includes(msgId)) return;
const body = message.getPlainBody();
const subject = message.getSubject();
// --- 正規表現による抽出 ---
const dateMatch = body.match(/(\d{4}[\/\-]\d{1,2}[\/\-]\d{1,2})/);
const timeMatch = body.match(/(\d{1,2}:\d{2})\s*[〜~\-]\s*(\d{1,2}:\d{2})/);
const locationMatch = body.match(/場所:\s*(.+)/);
const dateStr = dateMatch ? dateMatch[1] : '';
const startStr = timeMatch ? timeMatch[1] : '';
const endStr = timeMatch ? timeMatch[2] : '';
const location = locationMatch ? locationMatch[1].trim() : '';
// 日程情報がないメールはスキップ
if (!dateStr || !startStr || !endStr) {
Logger.log(`日程情報が含まれていないためスキップ: ${subject}`);
return;
}
// 【安全な空き行の取得】シート全体の行数が1行だけの場合は2行目を追加
let targetRow = -1;
const maxRows = sheet.getMaxRows();
if (maxRows === 1) {
sheet.insertRowAfter(1);
targetRow = 2;
} else {
const titleValues = sheet.getRange(1, 3, maxRows, 1).getValues();
for (let i = 1; i < titleValues.length; i++) {
if (!titleValues[i][0]) {
targetRow = i + 1;
break;
}
}
if (targetRow === -1) {
sheet.insertRowAfter(maxRows);
targetRow = sheet.getMaxRows();
}
}
// 該当行にデータを書き込み
sheet.getRange(targetRow, 1, 1, 8).setValues([[
false,
'未確認',
subject,
dateStr,
startStr,
endStr,
location,
msgId
]]);
// A列にチェックボックスを生成
sheet.getRange(targetRow, 1).insertCheckboxes();
// B列にドロップダウンを動的に自動設定
sheet.getRange(targetRow, 2).setDataValidation(statusRule);
});
// 処理済みラベルを付与して既読化
thread.addLabel(processedLabel);
thread.markRead();
});
}
5. トリガーの設定(2種類)
GASエディタ左側の時計アイコン(トリガー)を開き、右下の「+ トリガーを追加」から以下の2つを設定します。
① スプレッドシート編集時トリガー(カレンダー登録用)
-
実行する関数:
handleCheckboxEdit -
実行するデプロイ:
Head -
イベントのソース:
スプレッドシートから -
イベントの種類:
編集時
Note: なぜシンプルトリガー
onEdit(e)を使わないのか?
function onEdit(e)という名前で定義するシンプルトリガーはセキュリティ制約が厳しく、Googleカレンダーの操作(CalendarApp)が許可されていません。そのため、別の関数名にして「インストーラブルトリガー(編集時)」として明示的に登録する必要があります。
② 時間主導型トリガー(メール巡回用)
-
実行する関数:
fetchEmailsToSheet -
実行するデプロイ:
Head -
イベントのソース:
時間主導型 -
時間ベースのトリガーのタイプ:
分ベースのタイマー -
時間の間隔:
15分おき(お好みで調整)
6. 実装・運用時のハマりどころとTips
① 日時形式のパースエラー(シリアル値問題)
スプレッドシートに 14:00 と書き込まれたセルを GAS の getValue() で取得すると、文字列ではなく「1899年12月30日 14:00:00」の Date 型オブジェクトとして扱われることがあります。
単純に文字列結合すると new Date() に失敗するため、本コードの combineDateAndTime() では Utilities.formatDate() を使って安全にフォーマットを再構成しています。
② 空チェックボックスによる行飛び問題
事前にシート下部まで空のチェックボックスを敷き詰めておくと、GASの sheet.appendRow() や getLastRow() が「データあり」と誤認して、空きスペースを飛ばしたはるか下の行に書き込んでしまう原因になります。
今回のコードでは、追記時に sheet.getRange(...).insertCheckboxes() で1行ずつチェックボックスを動的生成するようにして対策しています。
7. おわりに
「全自動」は魅力的ですが、ビジネスの予定調整においては「抽出は自動化し、決定・確定のワンクリックだけ人間が握る」という半自動化が最も安全で実用的なバランスです。
メールの本文フォーマットに合わせて正規表現部分をカスタマイズすれば、予約フォーム通知、お問い合わせフォーム、各種日程調整ツールからのメールなど幅広く応用できますので、ぜひ試してみてください。