2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【GAS】GoogleカレンダーとOutlook予定表の同期(ほぼリアタイ)をさせてみたメモ。

2
Last updated at Posted at 2026-06-10

ごあいさつ

こんにちは。今回、記事を書かせていただくことになりました、bataと申します。
今回は、弊社の記事に珍しくAIとは無関係の記事となっておりますので、ご了承の上お読みいただけますと幸いです。

今日は何するの?

弊社の代表、実はいくつか会社をもっておりまして。
そうなると、なかなか統一化しづらいものの一つがスケジューラではないでしょうか!(個人用と仕事用とか、私たちでもわけますし!ね!ね!)
と、いうわけで( 強引だなぁ。。。CV:雨穴さん )、せっかくなので、未経験言語のお勉強もかねて実践していきたいと思います!

前提条件

まず、今回の対象となるアカウントは、全体にスケジュールを共有したい1人のみのものになります。

要望 teams側で登録した予定 Googleカレンダー側で登録した予定
表示有無 マスキング あり
表示方法 「予定あり」表示 タイトル・詳細を表示させる

また、今回はGASで良いのではないか?というマネージャーのアドバイスもあり、言語はGAS(ほぼJS)で実施します。

➡まず実施したこと

一般的なカレンダー同期方法を、下記に書き出しておきます。

ご存じの方はスキップで大丈夫です。

◇GoogleカレンダーからOutlookへ

①PC版Googleカレンダーから、共有したいカレンダーのハンバーガーアイコン(縦の三点リーダ)から、設定と共有を選択
FireShot Capture 001 - Google カレンダー - 2026年 6月 1日の週 - [calendar.google.com].png

②マイカレンダーの設定内の、カレンダーの統合より、iCal形式の非公開URLをコピー
スクリーンショット 2026-06-03 110819.png

③OutlookのWEB版(←重要!!)にログインし、予定表の追加を押下する。
スクリーンショット 2026-06-03 094213.png
④WEBから定期受信→先程コピーしたiCal形式の非公開URLを貼り付け→インポートボタンを押下する。

◇OutlookからGoogleカレンダーへ

①OutlookのWEB版(←重要!!)にログインし、設定(歯車)を押下する。
②予定表→共有予定表→予定表を公開する→予定表選択
 (いくつか公開の段階がありますが、今回は予定の入っている時間のみで)
スクリーンショット 2026-06-03 105600.png
③公開ボタンを押すと、HTMLとICSが作成されるので、ICSをコピーする。
④PC版Googleカレンダーで、他のカレンダー横の「+」ボタン押下→URLで追加
⑤先程のICSを貼り付け→カレンダーを追加を押下する。

これの何がイケてないの?

このやり方だと、リアルタイムに同期ができないので、ブッキングが発生しやすくなります。
Microsoftの公式ドキュメントによると、

注: この更新プログラムには 24 時間以上かかることがありますが、更新は約 6 時間ごとに行われます。

とのことで、更新時間が完全にサーバー側で管理されている以上、このままでは同期させても、あまり意味がありません。
Power Automateなどを利用する手法もあるのですが、無料版では同期が手動となるので、あまり旨味がありません。
可能なら、自動化したい…!

じゃあどうするの?

同期の仕組みとしては、Microsoftのサーバーが、定期的にGoogleカレンダーのURLへ「新しい予定はないか?」とデータを取りに行く(プル型)仕様になっているようです。
ならば、iCalendarがあるので、GAS(Google Apps Script)のトリガーで、定期的に最新のデータを取り出せばよいのでは…?
ということで、実際に書いていきます。

お試しがてら、実装してみる

全体構成図

上記のように、Microsoft側からのアプローチが塞がれた状態なので、「GAS側からOutlookへ、直接iCalendar形式の会議出席依頼を送付する」という手法を取っています。( 力技だなぁとか言わない )
これにより、完全に個人設定だけでリアルタイム(風)双方向同期を実現していきます。

実装手順

1️⃣Googleカレンダーに、共有用カレンダーを作成
PC版Googleカレンダーで、他のカレンダー横の「+」ボタン押下→新しいカレンダーを作成
スクリーンショット 2026-06-03 153212.png

名前を入力し、カレンダーを作成を押下する。
➡ マイカレンダーの設定より、カレンダーの統合→カレンダー ID を取得しておく。

2️⃣Outlookの予定表より、iCalendarを取得
上述、◇OutlookからGoogleカレンダーへの③まで実施する。

3️⃣Gmail APIの有効化
「会議出席依頼メールの送信」には、MIME形式の生データを扱うために、Gmail APIが必須となります。

スクリーンショット 2026-06-03 142401.png

GASエディタの左メニューにある「サービス(+マーク)」をクリック。
➡「Gmail API」 を選択して追加。

4️⃣ソースコードを配置する
先に、最終的なソースコードを確認したい方はこちら。

5️⃣AppsScriptの環境設定を行う
AppsScriptより、対象のプロジェクトを選択→プロジェクトの設定→スクリプト プロパティを下記の通り設定する。
スクリーンショット 2026-06-03 161051.png

・PROP_COMPANY_EMAIL: ~@会社ドメイン(.co.jp)
・PROP_ICS_URL: 2️⃣で取得した .ics
・PROP_MY_MAIN_CAL_ID: 共有するGoogleカレンダーのメールアドレス(@gmail.com)
・PROP_TARGET_CAL_ID: 1️⃣で作成したカレンダーのID

スクリーンショット 2026-06-03 163249.png
次いで、トリガー→トリガーを追加を押下する。
実行する関数(run)を選択し、時間ベース(分ベースなど)や時間の間隔(10分おきなど)を選択して保存する。

技術的なハマりポイントと解決策

🔶 Outlookに「添付ファイル」ではなく「招待状」として認識させる

初期段階では、Content-Type の存在は知っていたけれど、calendarがあることに気づいておらず、Outlook側で「ただのファイル付きメール
(mixed)」として処理され、カレンダーに反映されませんでした。( 確認って大事だよねえ。。。と、しみじみ。 )
そして、メールヘッダーのmethod=REQUEST(追加・変更)を受信すると、Outlook側で「BEGIN:VCALENDARから始まるデータ」があるはす→解析する仕組みのようです。

let rawMessage = [
    'To: ' + companyEmail,
    'Subject: ' + encodeSubject(subject),
    'MIME-Version: 1.0',
    'Content-Type: text/calendar; method=' + method + '; charset=UTF-8',
    'Content-Transfer-Encoding: 7bit',
    '',
    icsContent
  ].join('\r\n');

蛇足ですが、調べたところ、標準規格に、
iCalendar(RFC5545): 予定の開始時間や件名を BEGIN:VEVENT〜END:VEVENT で表現するデータ形式と、
iTIP(RFC6047): そのカレンダーデータをメール等でどうやって送受信するかというルールがあるそうで。
ほほう、bataはまた一つ賢くなったぞ!

※ここから、少々ハックのお話。
今回、Gmail APIとRaw(生)データ送信送信を行っているのですが、理由があります。
GASでよく使われる「MailApp.sendEmail()」や、普通の「GmailApp.sendEmail()」は、GAS側が裏で勝手に「普通のメール用のヘッダー(text/mixedなど)」に成形してしまう特徴があります。
(ホスピタリティ溢れるGoogleさん…!でも、それじゃ困っちゃうのよ…!!!)
その部分を解消するのが、コードの以下の箇所。

Gmail.Users.Messages.send({ raw: base64EncodedMessage }, 'me');

raw(生)の名前の通り、GASに対して「余計な補正はしないでね!私がヘッダーから文字コードまで全部完璧に組み立てたから、このバイナリデータ(Base64)のまま相手のサーバーに投げつけてね!」と指示を出しているわけです。
これにより、無事に添付ファイルではなく、招待状として認識させることに成功ました。
ざっくり言うと、Gmail APIでRawデータを送ってヘッダーを乗っ取った話。ってタイトルつきそうですねぇ。

🔶 終日予定のタイムゾーンの罠

これは、普通にうっかりをしてしまった話なのですが( ボケるにはまだ早いぞ! )
Outlook側の終日予定を、通常の予定と同じ感覚で「日時(Time)」付きのフォーマットのままGoogleカレンダー側へ登録しようとすると、とんでもない事態が発生します。
(例:2026-06-02 00:00:00 JST をUTCに変換した 20260601T150000Z のようなフォーマットです。)

何が起きたか?
➡ Googleカレンダーの上部にある、終日予定エリアではなく、深夜0時の時間帯からスタートする「24時間の細長い帯」が通常スケジュール内にズドーンと作成されていたのです…!!
これは非常に宜しくない。

どうやら、Googleカレンダー側の脳内では、

・世界標準時(UTC)で6/1の15時開始
 ↓
・ユーザーのタイムゾーンは日本(JST = UTC+9)だから、
 日本時間の6/2の朝0時から、24時間の時間指定予定として登録する

と解釈されてしまった模様。

ということで!
iCalendarのデータ長から終日を判定し、createAllDayEventで対処することにしました。

iCalendarの仕様では、終日予定は時間を含めず DTSTART:20260602 のように「8文字の日付のみ」でデータが送られてきます。
なので、「文字列が8文字かどうか」をパーサーで検知して isAllDay = true というフラグを立てて、
Googleカレンダーに登録する際は、終日予定専用の関数へ処理を動的にスイッチングすることで、終日予定エリアへと格納させてみました。

■ ICSファイルのパース部(データの長さを検知)
.icsファイルを行ごとに解析する際、DTSTARTの値の長さが「8文字」だった場合、終日予定フラグをセットします。

// parseIcs(icsStr) 内の処理から抜粋
if (match[1] === 'DTSTART') {
  currentEvent.start = parseIcsDate(match[2]);
  
  // ★日付文字列が8文字(時間情報なし)なら終日予定としてマークする
  if (match[2].length === 8) {
    currentEvent.isAllDay = true;
  }
}

■ Googleカレンダーへの反映部(時間変更の比較、登録関数の出し分け)
同期を実行するロジックです。すでに登録済みの予定が終日予定に変わった場合の検知や、新規作成時の出し分けを行っています。

for (const event of teamsEvents) {
  if (event.start < minDate || event.start > maxDate) continue;

  const eventKey = event.uid;
  seenKeys.add(eventKey);

  let targetEvent = null;
  if (map[eventKey]) {
    try { targetEvent = targetCal.getEventById(map[eventKey]); } catch (err) { targetEvent = null; }
  }

  if (targetEvent) {
    // ★【時差対策】終日か時間指定かで、時間の比較方法を動的に変える
    const timeChanged = event.isAllDay ? 
      (targetEvent.getStartTime().toYYYYMMDDString() !== event.start.toYYYYMMDDString()) :
      (targetEvent.getStartTime().getTime() !== event.start.getTime() || targetEvent.getEndTime().getTime() !== event.end.getTime());

    // 時間が変わった、または「終日設定」自体が切り替わった場合
    if (timeChanged || targetEvent.isAllDayEvent() !== event.isAllDay) {
      if (event.isAllDay) {
        // ★終日予定への変更は、一度既存の通常予定を消して作り直すのが確実
        targetEvent.deleteEvent();
        targetEvent = targetCal.createAllDayEvent(TITLE_FROM_TEAMS, event.start, { visibility: VISIBILITY });
        map[eventKey] = targetEvent.getId();
      } else {
        targetEvent.setTime(event.start, event.end);
      }
      targetEvent.setTitle(TITLE_FROM_TEAMS);
      targetEvent.setVisibility(VISIBILITY);
      targetEvent.setTransparency(TRANSPARENCY);
    }
  } else {
    // ★新規作成時:終日と通常予定で、使用するGASの関数をわける
    let newEvent;
    if (event.isAllDay) {
      // 終日エリアに収める
      newEvent = targetCal.createAllDayEvent(TITLE_FROM_TEAMS, event.start, {
        visibility: VISIBILITY,
        description: 'Synced directly from Outlook ICS',
      });
    } else {
      // 通常の時間枠に収める
      newEvent = targetCal.createEvent(TITLE_FROM_TEAMS, event.start, event.end, {
        visibility: VISIBILITY,
        description: 'Synced directly from Outlook ICS',
      });
    }
    newEvent.setTransparency(TRANSPARENCY);
    map[eventKey] = newEvent.getId();
  }
}

色々捏ね繰り回し、上記の形式で無事に終日予定エリアへと格納無事にさせることができました。( あー。焦り申した。。。 )

🔶 削除(キャンセル)と件名変更の追従

おそらく、これがカレンダー周りで一番厄介な部分だと思います。
Googleカレンダー側でリスケやキャンセルがなされたとき、それを会社のOutlook側へどうやって伝えるか?が 万里の長城 大きな壁になると思います。

何かといいますと、トリガーが動いた瞬間、GASは今カレンダーにある最新のデータしか検知することができないので、
すでに削除されてしまった予定は、カレンダー上から消えているため、GAS側で検知できない仕様なのです。( OMG。。。 )

なら、どうするか?
今回は、GASの内部データベース(ScriptProperties)を利用し、前回送信した時点のスケジュール状態を、GoogleのEventIdをキーにしたJSONマップとして記憶させてみました。

// ScriptPropertiesに保存されるマッピングデータのイメージ
{
  "google_event_id_abc123": {
    "start": 1780368000000, 
    "end": 1780371600000, 
    "title": "歯医者(定期検診)"
  }
}

実行時に、「現在の最新データ」と「DBに保存されている過去のデータ」を比較することで、以下のように差分(変更・削除)を検知させています。

・変更の検知: IDは一致するが、「開始時間」「終了時間」「件名」のいずれかが変化していれば、上書きの招待状を再送。
・削除の検知: DBにIDが残っているのに、現在のカレンダーからそのIDが消えていたら「削除した」と判断。過去の記憶から日時とタイトルを引っ張り出し、キャンセル通知を送信。
 (METHOD:CANCEL/STATUS:CANCELLEDを仕込んだMIMEデータにします。)

■ 差分検知とステート更新のループ処理
Googleカレンダーの最新状態と、プロパティから読み込んだマップを比較し、変更や削除を洗い出すロジックです。

// syncGoogleToTeams() 内の処理から抜粋
const googleEvents = mainCal.getEvents(minDate, maxDate);
const map = JSON.parse(props.getProperty(PROP_MAP_GOOGLE_TO_OUTLOOK) || '{}');
const seenKeys = new Set();

// ◆新規作成 & 変更(変更の追従)の検知
for (const gEvent of googleEvents) {
  const eventKey = gEvent.getId();
  seenKeys.add(eventKey); // カレンダーに現存するIDを記録

  const start = gEvent.getStartTime();
  const end = gEvent.getEndTime();
  const title = gEvent.getTitle() || '無題の用事';
  const description = gEvent.getDescription() || 'Googleカレンダーから同期された詳細情報です。';
  
  const savedData = map[eventKey]; // 過去の送信履歴を取得

  if (savedData) {
    // ★過去のデータと比較し、時間または「タイトル」が変わっていたら再送(更新)
    if (savedData.start !== start.getTime() || 
        savedData.end !== end.getTime() || 
        savedData.title !== title) {
      
      // METHOD: 'REQUEST' で上書き招待状を送信
      sendOutlookInvite(start, end, title, description, eventKey, 'REQUEST');
      
      // 記憶(ステート)を最新版にアップデート
      map[eventKey] = { start: start.getTime(), end: end.getTime(), title: title };
    }
  } else {
    // 新規予定の場合も同様に送信して記憶
    sendOutlookInvite(start, end, title, description, eventKey, 'REQUEST');
    map[eventKey] = { start: start.getTime(), end: end.getTime(), title: title };
  }
}

// ◆削除(キャンセル)の検知
for (const key of Object.keys(map)) {
  // ★過去の記憶にあるのに、今回のカレンダー巡回(seenKeys)に含まれていない = 削除された予定
  if (!seenKeys.has(key)) {
    // 記憶しておいた過去の時間とタイトルを取り出す
    const oldStart = new Date(map[key].start);
    const oldEnd = new Date(map[key].end);
    const oldTitle = map[key].title || '用事';
    
    // METHOD: 'CANCEL' でOutlook側にカレンダー枠の削除を通知
    sendOutlookInvite(oldStart, oldEnd, oldTitle, '', key, 'CANCEL');
    
    // データベースから削除
    delete map[key];
  }
}
// 最新のステートをデータベースに保存
props.setProperty(PROP_MAP_GOOGLE_TO_OUTLOOK, JSON.stringify(map));

余談ですが、DBのIDの認識が変わるような変更したりなどすると、無限に予定が増えていく現象が起きます。。。
その場合は、スクリプトプロパティの「MAP_OUTLOOK_TO_GOOGLE_V2」「MAP_GOOGLE_TO_OUTLOOK_V2」を削除して実行してみてください。

■ iCalendar(ICS)テキストへの埋め込み部
引数で受け取ったmethod(REQUESTまたはCANCEL)を元に、Outlookがカレンダーを上書きするか?消去するか?を決定するヘッダー構文を動的に組み立てます。

// sendOutlookInvite() 内の処理から抜粋

// ★消去(CANCEL)の場合は、件名の頭に「キャンセル: 」を付与して視認性を高める
const subject = method === 'CANCEL' ? 'キャンセル: ' + title : title;

let icsContent = [
  'BEGIN:VCALENDAR',
  'PRODID:-//Google Inc//Google Calendar 70.9054//EN',
  'VERSION:2.0',
  'CALSCALE:GREGORIAN',
  'METHOD:' + method, // ★REQUEST または CANCEL が動的に入る
  'BEGIN:VEVENT',
  'DTSTAMP:' + nowStr,
  'DTSTART:' + startStr,
  'DTEND:' + endStr,
  'SUMMARY:' + escapeIcsText(title),
  'DESCRIPTION:' + escapeIcsText(description),
  'UID:' + uid,       // ★このUIDが一致しているからこそ、Outlookはどの予定を操作すべきか判別できる
  'SEQUENCE:' + (method === 'CANCEL' ? '2' : '1'),
  'STATUS:' + (method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'), // ★CANCELLED で完全消去を指示
  'TRANSP:OPAQUE',
  'END:VEVENT',
  'END:VCALENDAR'
].join('\r\n');

GoogleカレンダーのEventIdを、そのままICSのUIDに流し込んでいるため、Outlook側は「以前に届いた予定の変更(キャンセル)だな」と検知できるようにしました。
また、iCalendarの規格に則り、キャンセル時は SEQUENCE: 2 / STATUS: CANCELLED を送ることで、Outlook側でも自動でカレンダーから枠が消え去る挙動を実現できました。
(「取り消された会議」としてグレーアウトするパターンも対応させました。)

UIDの一貫性や、SEQUENCEとSTATUSの制御もうまくいったので、ほっと一息。
何度か実行させて、正常に追従や削除が実行できるのを確認できました。(長かった。。。)

最終的な全体ソース

// 各種カレンダーID保存用のスクリプトプロパティのキー名
const PROP_TARGET_CAL_ID = 'PROP_TARGET_CAL_ID'; // ブロック用カレンダーID
const PROP_MY_MAIN_CAL_ID = 'PROP_MY_MAIN_CAL_ID'; // プライベートカレンダーID
const PROP_MAP_OUTLOOK_TO_GOOGLE = 'MAP_OUTLOOK_TO_GOOGLE_V2';
const PROP_MAP_GOOGLE_TO_OUTLOOK = 'MAP_GOOGLE_TO_OUTLOOK_V2';

const TITLE_FROM_TEAMS = '予定あり'; // Google側に表示する文字
const TITLE_TO_OUTLOOK = '予定あり(Googleカレンダーより)'; // Teams側に表示する文字
const VISIBILITY = CalendarApp.Visibility.PRIVATE;
const TRANSPARENCY = CalendarApp.EventTransparency.OPAQUE;

/**
 * メイン同期処理(icsを解析)
 */
function run() {
  // Teams -> Google への同期
  try {
    syncTeamsToGoogle();
  } catch (e) {
    console.error('TeamsからGoogleへの同期中にエラーが発生しました: ' + e.message);
  }

  // Google -> Teams への同期
  try {
    syncGoogleToTeams();
  } catch (e) {
    console.error('GoogleからTeamsへの同期中にエラーが発生しました: ' + e.message);
  }
}

// Teams -> Google 同期処理(ics解析)
function syncTeamsToGoogle() {
  const props = PropertiesService.getScriptProperties();
  const TARGET_CAL_ID = props.getProperty(PROP_TARGET_CAL_ID);
  const targetCal = CalendarApp.getCalendarById(TARGET_CAL_ID);
  if (!targetCal) return;

  // Outlookから取得した「https://.../calendar.ics」のURL
  const icsUrl = props.getProperty('PROP_ICS_URL');
  if (!icsUrl) {
    console.error('ICS_URLが設定されていません。スクリプトプロパティを確認してください。');
    return;
  }

  const response = UrlFetchApp.fetch(icsUrl);
  const teamsEvents = parseIcs(response.getContentText());
  const map = JSON.parse(props.getProperty(PROP_MAP_OUTLOOK_TO_GOOGLE) || '{}');
  const seenKeys = new Set();

  const now = new Date();
  const minDate = new Date(now.getTime() - 1 * 24 * 3600 * 1000);
  const maxDate = new Date(now.getTime() + 60 * 24 * 3600 * 1000);

  for (const event of teamsEvents) {
    if (event.start < minDate || event.start > maxDate) continue;

    const eventKey = event.uid;
    seenKeys.add(eventKey);

    let targetEvent = null;
    if (map[eventKey]) {
      try { targetEvent = targetCal.getEventById(map[eventKey]); } catch (err) { targetEvent = null; }
    }

    if (targetEvent) {
      // 終日か時間指定かで時間の比較方法を変える
      const timeChanged = event.isAllDay ? 
        (targetEvent.getStartTime().toYYYYMMDDString() !== event.start.toYYYYMMDDString()) :
        (targetEvent.getStartTime().getTime() !== event.start.getTime() || targetEvent.getEndTime().getTime() !== event.end.getTime());

      if (timeChanged || targetEvent.isAllDayEvent() !== event.isAllDay) {
        if (event.isAllDay) {
          // 終日予定への変更は一度消して作り直す
          targetEvent.deleteEvent();
          targetEvent = targetCal.createAllDayEvent(TITLE_FROM_TEAMS, event.start, { visibility: VISIBILITY });
          map[eventKey] = targetEvent.getId();
        } else {
          targetEvent.setTime(event.start, event.end);
        }
        targetEvent.setTitle(TITLE_FROM_TEAMS);
        targetEvent.setVisibility(VISIBILITY);
        targetEvent.setTransparency(TRANSPARENCY);
      }
    } else {
      // 新規作成(終日と通常予定で分ける)
      let newEvent;
      if (event.isAllDay) {
        newEvent = targetCal.createAllDayEvent(TITLE_FROM_TEAMS, event.start, {
          visibility: VISIBILITY,
          description: 'Synced directly from Outlook ICS',
        });
      } else {
        newEvent = targetCal.createEvent(TITLE_FROM_TEAMS, event.start, event.end, {
          visibility: VISIBILITY,
          description: 'Synced directly from Outlook ICS',
        });
      }
      newEvent.setTransparency(TRANSPARENCY);
      map[eventKey] = newEvent.getId();
    }
  }

  for (const key of Object.keys(map)) {
    if (!seenKeys.has(key)) {
      try {
        const targetEvent = targetCal.getEventById(map[key]);
        if (targetEvent) targetEvent.deleteEvent();
      } catch (err) {}
      delete map[key];
    }
  }
  props.setProperty(PROP_MAP_OUTLOOK_TO_GOOGLE, JSON.stringify(map));
}

// Google -> Teams 同期処理(メール招待状送信)
function syncGoogleToTeams() {
  const props = PropertiesService.getScriptProperties();
  const MAIN_CAL_ID = props.getProperty(PROP_MY_MAIN_CAL_ID);
  const mainCal = CalendarApp.getCalendarById(MAIN_CAL_ID);
  if (!mainCal) return;

  const now = new Date();
  const minDate = new Date(now.getTime() - 1 * 24 * 3600 * 1000);
  const maxDate = new Date(now.getTime() + 60 * 24 * 3600 * 1000);

  // 個人のメインカレンダーから予定を取得
  const googleEvents = mainCal.getEvents(minDate, maxDate);
  const map = JSON.parse(props.getProperty(PROP_MAP_GOOGLE_TO_OUTLOOK) || '{}');
  const seenKeys = new Set();

  for (const gEvent of googleEvents) {
    const eventKey = gEvent.getId();
    seenKeys.add(eventKey);

    const start = gEvent.getStartTime();
    const end = gEvent.getEndTime();

    // Googleカレンダーの「タイトル」と「説明(メモ)」を取得
    const title = gEvent.getTitle() || '無題の用事';
    const description = gEvent.getDescription() || 'Googleカレンダーから同期された詳細情報です。';

    // 以前にこの予定について招待メールを送ったか確認
    const savedData = map[eventKey];

    if (savedData) {
      // すでに送信済みの場合、時間または「タイトル」が変わっていたら再送(更新)
      if (savedData.start !== start.getTime() || 
          savedData.end !== end.getTime() || 
          savedData.title !== title) {
        // 時間が変わっていたら「更新用」の招待状を送信(上書き処理)
        sendOutlookInvite(start, end, title, description, eventKey, 'REQUEST');
        map[eventKey] = { start: start.getTime(), end: end.getTime(), title: title };
      }
    } else {
      // 新規予定を詳細付きで送信
      sendOutlookInvite(start, end, title, description, eventKey, 'REQUEST');
      map[eventKey] = { start: start.getTime(), end: end.getTime(), title: title };
    }
  }

  // Google側で削除された予定を検知したら、Outlook側にキャンセル通知を送る
  for (const key of Object.keys(map)) {
    if (!seenKeys.has(key)) {
      // 過去に記録した時間でキャンセル通知を送信
      const oldStart = new Date(map[key].start);
      const oldEnd = new Date(map[key].end);
      const oldTitle = map[key].title || '用事';
      sendOutlookInvite(oldStart, oldEnd, oldTitle, '', key, 'CANCEL');
      delete map[key];
    }
  }
  props.setProperty(PROP_MAP_GOOGLE_TO_OUTLOOK, JSON.stringify(map));
}

/**
 * 会社宛てにiCalendar形式の会議出席依頼(またはキャンセル)メールを送信する
 * @param {Date} start 開始時刻
 * @param {Date} end 終了時刻
 * @param {string} uid 予定の一意識別子
 * @param {string} method 'REQUEST'(追加・変更)または 'CANCEL'(削除)
 */
function sendOutlookInvite(start, end, title, description, uid, method) {
  const props = PropertiesService.getScriptProperties();

  // 会社のメールアドレス(ここに招待状が届きます)
  const companyEmail = props.getProperty('PROP_COMPANY_EMAIL');
  if (!companyEmail) {
    console.error('COMPANY_EMAILが設定されていません。スクリプトプロパティを確認してください。');
    return;
  }

  const startStr = formatToIcsTime(start);
  const endStr = formatToIcsTime(end);
  const nowStr = formatToIcsTime(new Date());

  // Outlookが会議情報として認識するためのICSテキスト
  let icsContent = [
    'BEGIN:VCALENDAR',
    'PRODID:-//Google Inc//Google Calendar 70.9054//EN',
    'VERSION:2.0',
    'CALSCALE:GREGORIAN',
    'METHOD:' + method,
    'BEGIN:VEVENT',
    'DTSTAMP:' + nowStr,
    'DTSTART:' + startStr,
    'DTEND:' + endStr,
    'SUMMARY:' + escapeIcsText(title),
    'DESCRIPTION:' + escapeIcsText(description),
    'UID:' + uid,
    'SEQUENCE:' + (method === 'CANCEL' ? '2' : '1'),
    'STATUS:' + (method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'),
    'TRANSP:OPAQUE',
    'END:VEVENT',
    'END:VCALENDAR'
  ].join('\r\n');

  // メールのヘッダーと中身
  const subject = method === 'CANCEL' ? 'キャンセル: ' + title : title;
  
  // UTF-8文字列をBase64でエンコードする
  const encodeSubject = (str) => '=?UTF-8?B?' + Utilities.base64Encode(str, Utilities.Charset.UTF_8) + '?=';

  let rawMessage = [
    'To: ' + companyEmail,
    'Subject: ' + encodeSubject(subject),
    'MIME-Version: 1.0',
    'Content-Type: text/calendar; method=' + method + '; charset=UTF-8',
    'Content-Transfer-Encoding: 7bit',
    '',
    icsContent
  ].join('\r\n');

  // Base64に変換して、Gmailから送信
  const base64EncodedMessage = Utilities.base64EncodeWebSafe(rawMessage, Utilities.Charset.UTF_8);
  
  // 初回実行時に「高度なGoogleサービス」の承認、許可を求められた場合は「許可」すること
  Gmail.Users.Messages.send({ raw: base64EncodedMessage }, 'me');
}

// 共通ヘルパー
function escapeIcsText(str) {
  if (!str) return '';
  return str
    .replace(/\\/g, '\\\\')
    .replace(/\n/g, '\\n')
    .replace(/,/g, '\\,')
    .replace(/;/g, '\\;');
}

// ICSファイルのパーサー関数(簡易)`
function parseIcs(icsStr) {
  const events = [];
  const lines = icsStr.replace(/\r\n /g, '').replace(/\n /g, '').split(/\r?\n/);
  let currentEvent = null;
  for (let line of lines) {
    if (line === 'BEGIN:VEVENT') currentEvent = {};
    else if (line === 'END:VEVENT') {
      if (currentEvent && currentEvent.uid && currentEvent.start && currentEvent.end) events.push(currentEvent);
      currentEvent = null;
    } else if (currentEvent) {
      const match = line.match(/^([^;:]+)(?:;[^:]*)?:(.*)$/);
      if (!match) continue;
      if (match[1] === 'UID') currentEvent.uid = match[2];
      else if (match[1] === 'DTSTART') {
        currentEvent.start = parseIcsDate(match[2]);
        if (match[2].length === 8) currentEvent.isAllDay = true;
      } else if (match[1] === 'DTEND') currentEvent.end = parseIcsDate(match[2]);
    }
  }
  return events;
}

// ICSの日時文字列(例: 20260601T103000Z)をJavaScriptのDateオブジェクトに変換
function parseIcsDate(icsDateStr) {
  if (icsDateStr.length === 8) {
    return new Date(parseInt(icsDateStr.substring(0, 4), 10), parseInt(icsDateStr.substring(4, 6), 10) - 1, parseInt(icsDateStr.substring(6, 8), 10));
  }
  const y = parseInt(icsDateStr.substring(0, 4), 10);
  const m = parseInt(icsDateStr.substring(4, 6), 10) - 1;
  const d = parseInt(icsDateStr.substring(6, 8), 10);
  const h = parseInt(icsDateStr.substring(9, 11), 10);
  const min = parseInt(icsDateStr.substring(11, 13), 10);
  const s = parseInt(icsDateStr.substring(13, 15), 10);
  return icsDateStr.endsWith('Z') ? new Date(Date.UTC(y, m, d, h, min, s)) : new Date(y, m, d, h, min, s);
}

// ICSの日時文字列へ変換
function formatToIcsTime(date) {
  const pad = num => ('0' + num).slice(-2);
  return date.getUTCFullYear() +
    pad(date.getUTCMonth() + 1) +
    pad(date.getUTCDate()) + 'T' +
    pad(date.getUTCHours()) +
    pad(date.getUTCMinutes()) +
    pad(date.getUTCSeconds()) + 'Z';
}

というわけで、長々と語って参りましたが、こちらが全体像となります。

因みに、1日前から60日後までのカレンダーを取得してますが、下記の箇所を変更すれば取得範囲を変更できます。

// 1日前までのカレンダーを取得する
const minDate = new Date(now.getTime() - 1 * 24 * 3600 * 1000);
// 60日後までのカレンダーを取得する
const maxDate = new Date(now.getTime() + 60 * 24 * 3600 * 1000);

この仕組みのデメリットもお伝えしておきますと、
・メールにカレンダーを紐づけているので、都度メールが送付される。
・スケジュールが膨大にある場合、Gmail APIの送信制限に引っかかる可能性がある。
という点に注意していただければと思います。

( そもそも、システム導入制限がなければ、こんなことしなくても良い気もするのですが。。。 )

というわけで、今回はここまで。
この記事が、多くの「縛り環境」で戦うエンジニアの救いになりますように!

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?