GmailからChatworkへ転送する際は、対象メールと投稿先を先に限定します。
改訂版では、専用ラベルのスレッド内にある未読メッセージを選び、送信済みのIDを除いてから新しい順に最大3件を処理します。
架空のメールで転送内容を確かめる
通知連携の練習コードとローカルテストをダウンロード(ZIP)。
各article-番号.jsを別々のApps Scriptプロジェクトへ保存します。
article-646.jsを別のApps Scriptプロジェクトへ保存し、demoGmailを実行します。
架空の未読メールだけを候補にし、GmailやChatworkへアクセスせず、改行を含む転送文を表示します。
// 各記事の例は別々のApps Scriptプロジェクトへ保存します。
function props_() { return PropertiesService.getScriptProperties(); }
function required_(key) {
const value = props_().getProperty(key);
if (!value) throw new Error(key + 'をスクリプトプロパティへ設定してください');
return value;
}
function chatConfig_() {
const room = required_('CHATWORK_ROOM_ID');
if (!/^\d+$/.test(room)) throw new Error('ルームIDは数字で指定します');
return {room: room, token: required_('CHATWORK_API_TOKEN')};
}
function plain_(text) {
return String(text).replace(/\[/g, '[').replace(/\]/g, ']');
}
function stateKey_(kind, destination, id) {
const digest = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256,
kind + ':' + destination + ':' + id, Utilities.Charset.UTF_8);
return 'CGM_JOB_' + Utilities.base64EncodeWebSafe(digest);
}
function preview_(plans) {
// 実メールや予定の本文はログへ出さない。demo関数で架空の本文を確認。
Logger.log(JSON.stringify(plans.map(p => ({id: p.id, characters: p.text.length}))));
return plans;
}
function sendControlled_(kind, destination, buildPlans, transport) {
if (props_().getProperty('SEND_ENABLED') !== 'true') throw new Error('送信は無効です。まずプレビューしてください');
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('別の実行が処理中です');
try {
const plans = buildPlans();
for (const plan of plans) {
const key = stateKey_(kind, destination, plan.id);
const state = props_().getProperty(key);
if (state === 'done') continue;
if (state) throw new Error('結果不明の処理があります。投稿先と記録を照合してください');
// 通信前に記録。通信成功後の記録失敗でも自動で再送しない。
props_().setProperty(key, 'sending');
try {
transport(plan.text);
props_().setProperty(key, 'done');
} catch (error) {
props_().setProperty(key, 'unknown');
throw new Error('送信結果を確定できません。自動再送せず投稿先を確認してください');
}
}
} finally { lock.releaseLock(); }
}
function sendChatwork_(text, config) {
const response = UrlFetchApp.fetch('https://api.chatwork.com/v2/rooms/' + config.room + '/messages', {
method: 'post', headers: {'X-ChatworkToken': config.token},
payload: {body: text, self_unread: 1}, muteHttpExceptions: true
});
if (response.getResponseCode() !== 200) throw new Error('Chatwork HTTP ' + response.getResponseCode());
const result = JSON.parse(response.getContentText());
if (!result.message_id) throw new Error('message_idがありません');
}
function gmailPlans_(messages) {
return messages.filter(m => m.unread).sort((a,b) => b.date - a.date).slice(0,3)
.map(m => ({id:m.id,text:'【メール転送】' + plain_(m.subject) + '\n' + plain_(m.body.slice(0,3000))}));
}
function readGmailPlans_() {
if (!GmailApp.getUserLabelByName('cgm-forward')) throw new Error('専用ラベルcgm-forwardがありません');
const messages = [];
GmailApp.search('label:cgm-forward is:unread',0,10).forEach(t => {
t.getMessages().forEach(m => {
if (m.isUnread()) messages.push({id:m.getId(),date:m.getDate().getTime(),unread:true,
subject:m.getSubject(),body:m.getPlainBody()});
});
});
const room = required_('CHATWORK_ROOM_ID');
const unsent = messages.filter(m => props_().getProperty(stateKey_('gmail', room, m.id)) !== 'done');
return gmailPlans_(unsent);
}
function demoGmail() {
const plans = gmailPlans_([{id:'mail-new',date:2,unread:true,subject:'練習メール',body:'架空の連絡です。'},
{id:'mail-old',date:1,unread:false,subject:'既読の練習',body:'転送しません。'}]);
Logger.log(JSON.stringify(plans));
return plans;
}
function previewGmail() { return preview_(readGmailPlans_()); }
function sendGmailToChatwork() {
const config = chatConfig_();
sendControlled_('gmail', config.room, readGmailPlans_, text => sendChatwork_(text, config));
}
未読の選択と重複送信の確認結果
| ローカル模擬テストの条件 | 確認した結果 |
|---|---|
| 同じスレッドに既読と未読のメール | 先頭メールを無条件で使わず、未読のメッセージだけを選ぶ |
| 送信済みの未読メールが先頭に3件 | 送信済みを先に除き、次の未送信メールを候補にする |
| 同じメッセージIDを再処理 | 同じ送信先のdone記録があれば再投稿しない |
| 送信後の通信・保存結果が不明 | 自動再送せず、投稿先との照合待ちで止める |
転送対象と送信先を設定する
Gmailに専用ラベルcgm-forwardを作り、まず架空の内容だけの練習メールへ付けます。
スクリプトプロパティへCHATWORK_ROOM_IDとCHATWORK_API_TOKENを設定し、previewGmailで候補を確認します。
検索は直近の該当10スレッドまでで、その中の未読メッセージを対象にします。
大量の未処理メールを全件回収する例ではなく、古い対象が範囲から外れる場合はラベルの対象や処理範囲を見直します。
本文は先頭3000文字までのプレーンテキストで、添付ファイルやHTML表示は転送しません。
本文中のChatwork記法の角括弧は全角に変え、メールに含まれる記法がそのまま通知指定として使われないようにしています。
ラベルは自動で外さず、既読状態も変更しません。
スレッド単位でラベルを外して新着メールの対象を失う処理を避け、送信済みの判定はメッセージIDと送信先で行います。
実データの件名や本文をログへ出さないよう、previewGmailのログはIDと文字数だけです。
内容を確認する場合はGmailの元メールを見て、外部へ転送してよい内容か判断します。
送信関数はsendGmailToChatworkです。
self_unreadは投稿者から見た未読状態であり、受信者の通知音やプッシュ通知の到着を保証する設定ではありません。
投稿条件はChatworkの投稿仕様、メッセージ単位の未読確認はGmailMessageの仕様で確認しています。
送信を有効にする前の確認と止め方
送信先を管理する本人の許可を得て、練習用の自分宛てに送る内容と送信先を確認してから、スクリプトプロパティのSEND_ENABLEDをtrueにします。
デモやプレビューの関数に送信トリガーを付けません。
送信する関数を一度手動で試し、投稿先・内容・保存状態を照合してから、必要な時間主導トリガーを1つだけ設定します。
旧プロジェクトや別のトリガーが同時に送ると、このプロジェクトのロックだけでは二重投稿を防げません。
通信前にsending、成功応答後にdoneを保存します。
通信や記録に失敗した場合はunknownなどの状態を残して止まり、自動で再送しません。
結果が不明な場合はSEND_ENABLEDをfalseにし、トリガーも停止して、投稿先に同じ内容が届いていないか確認します。
送信が確認できた記録はdoneへ整え、未送信を確認できた場合だけ再実行の準備をします。
送信済みの記録を消すと同じ対象を再送する可能性があります。
長期運用では記録容量と整理方法を決め、旧コード・設定を控えてから変更します。
戻す際は新しいトリガーを止め、保存したコードと設定へ戻します。
練習コードの検証範囲
2026年10月2日に、Node.jsでGoogle Apps Script・HTTP応答・シート・ロック・保存状態を模した55ケースを確認しました。
ZIPを新しいフォルダーへ展開し、node test_integration.cjsでも同じ結果を確認できます。
実際のGoogle認可、APIへの投稿、シート書き込み、時間主導トリガー、受信側の通知音は未検証です。
ローカルでの成功を、実サービスでの配信成功と扱わないでください。
関連記事






