gmail-unifierの送信直前に宛先・件名・本文・添付サイズを出し、ボタンひとつで送信を承認する

Obsidian の保管庫を「AI と共有する自分のナレッジレイク」にして、作業の多くを AI(Claude Code / Codex)に任せられるようにするための一式。 作者が自分の保管庫で実際に運用している仕組みから、個人の中身を抜いて汎用化したもの。
| フォルダ | 何か | 最初に読むもの |
|---|---|---|
knowledge-lake-kit/ | 保管庫の器。 フォルダ構成、AI への指示書(AGENTS.md)、各フォルダの README、テンプレート、運用の考え方 | START_HERE.md(人間)/ SETUP_FOR_AI.md(AI) |
agent-tooling/ | AI に持たせる道具。 スキル(/inbox・/prioritize など)、MCP(Gmail 横断・Google Tasks)、Claude Code の mod、フック、CLI、定期実行 | README.md(人間)/ FOR_AI.md(AI) |
git clone https://github.com/KaiMiyazawa/obsidian-ai-knowledge-lake.git
knowledge-lake-kit/START_HERE.md を上から順に進める。最後は AI がインタビューしながら保管庫を組み立てる。<クローンした場所>/agent-tooling/FOR_AI.md を読んで、この道具を私の保管庫に入れるのを手伝ってどちらも「AI が読んで、利用者に確認しながら進める」前提で書いてある。全部を一度に入れなくてよい。
you@gmail.com、<MAIN_LIST_ID> など)にしてある。MIT。ただし agent-tooling/outside-vault/gtasks-mcp/ は alvinjchoi/gtasks-mcp(MIT)を改造したもので、同フォルダの LICENSE に従う。
hooks/register.ts 124 lines1import type { McpToolResult, Register } from 'claude-code'
2
3const SERVER = 'gmail-unifier'
4const PREFIX = `mcp__${SERVER}__`
5const SEND_TOOLS = new Set(['send_message', 'send_reply', 'forward', 'send_draft'])
6
7const SEND = '送信する'
8const CANCEL = '送信しない'
9
10// 02_note/mail-sending-pitfalls.md: 自治体・学校・小さな組織あては合計2MB以下が目安。gmail-unifierの上限は25MB。
11const SMALL_ORG_LIMIT = 2 * 1024 * 1024
12const HARD_LIMIT = 25 * 1024 * 1024
13
14const BODY_PREVIEW = 600
15
16type SendArgs = {
17 to?: string[] | null
18 cc?: string[] | null
19 bcc?: string[] | null
20 subject?: string
21 body?: string | null
22 text?: string
23 html_body?: string | null
24 attachment_paths?: string[] | null
25 include_attachments?: boolean
26 message_handle?: string
27 reply_to_message_handle?: string | null
28 reply_all?: boolean
29 draft_handle?: string
30}
31
32const textOf = (r: McpToolResult) =>
33 r.content.map(b => (b.type === 'text' ? b.text : '')).join('\n').trim()
34
35const list = (xs?: string[] | null) => (xs && xs.length > 0 ? xs.join(', ') : undefined)
36
37const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…(以下${s.length - n}文字省略)` : s)
38
39const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)}MB`
40
41export const register: Register = on => {
42 // ボタンで承認された呼び出し。classic.PreToolUse でエンジンの許可確認を省く。
43 const approved = new Set<string>()
44
45 // gmail-unifier のサーバー指示「送信前にチャットで許可を取る」を、このダイアログで置き換える。
46 on('prompt.compose', async ($, e, next) => {
47 const composed = await next(e)
48 return {
49 sections: [
50 ...composed.sections,
51 {
52 id: 'send-confirm:policy',
53 scope: 'session',
54 text: 'gmail-unifier の送信系ツール(send_message / send_reply / forward / send_draft)は、呼ぶと send-confirm mod が宛先・件名・本文・添付を示したダイアログで本人の承認を取る。送信内容が固まったら、チャットで事前の許可を求めずにそのまま送信ツールを呼んでよい。拒否された場合は理由に従って直す。',
55 },
56 ],
57 }
58 })
59
60 on('tool.call', async ($, e, next) => {
61 const tool = String(e.tool)
62 const name = tool.slice(PREFIX.length)
63 if (!tool.startsWith(PREFIX) || !SEND_TOOLS.has(name)) return next(e)
64
65 const args = e as unknown as SendArgs
66 const lines: string[] = []
67
68 if (name === 'send_draft' && args.draft_handle) {
69 const draft = await $.mcp.call(SERVER, 'get_draft', { draft_handle: args.draft_handle })
70 lines.push('【下書きを送信】', clip(textOf(draft), BODY_PREVIEW * 2))
71 } else {
72 const origin = args.message_handle ?? args.reply_to_message_handle
73 if (origin) {
74 const original = await $.mcp.call(SERVER, 'read_message', { message_handle: origin })
75 const head = textOf(original).split('\n').slice(0, 4).join(' / ')
76 lines.push(`【${name === 'forward' ? '転送' : '返信'}】元メール: ${clip(head, 160)}`)
77 }
78 const to = list(args.to)
79 lines.push(`To: ${to ?? (origin ? '(元メールの差出人)' : '(なし)')}${args.reply_all ? ' ※全員に返信' : ''}`)
80 if (list(args.cc)) lines.push(`Cc: ${list(args.cc)}`)
81 if (list(args.bcc)) lines.push(`Bcc: ${list(args.bcc)}`)
82 if (args.subject) lines.push(`件名: ${args.subject}`)
83 const body = args.body ?? args.text ?? args.html_body ?? ''
84 lines.push('本文:', clip(body, BODY_PREVIEW))
85 if (name === 'forward' && args.include_attachments !== false) lines.push('元メールの添付も付く')
86 }
87
88 let total = 0
89 for (const path of args.attachment_paths ?? []) {
90 const size = await $.fs.stat(path).then(s => s.size, () => undefined)
91 if (size === undefined) return { deny: `send-confirm: 添付が見つからない: ${path}` }
92 total += size
93 lines.push(`添付: ${path.split('/').pop()} (${mb(size)})`)
94 }
95 if (total > HARD_LIMIT) return { deny: `send-confirm: 添付合計 ${mb(total)} が25MBを超えている。圧縮か分割をする。` }
96 if (total > SMALL_ORG_LIMIT) {
97 lines.push(`⚠ 添付合計 ${mb(total)}。自治体・学校・小さな組織あては2MB以下が目安(mail-sending-pitfalls.md)`)
98 }
99
100 const answer = await $.ui
101 .ask(`${lines.join('\n')}\n\nこの内容で送信しますか?`, {
102 header: '送信確認',
103 options: [SEND, CANCEL],
104 })
105 .catch(() => CANCEL)
106
107 if (answer !== SEND) {
108 const reason = answer === CANCEL ? '本人が送信を取り消した。' : `本人の指示: ${answer}`
109 return { deny: `send-confirm: ${reason}` }
110 }
111
112 approved.add(e.tool_use_id)
113 try {
114 return await next(e)
115 } finally {
116 approved.delete(e.tool_use_id)
117 }
118 })
119
120 on('classic.PreToolUse', ($, e, next) =>
121 approved.has(e.tool_use_id) ? { allow: true } : next(e),
122 )
123}
124