ゴール
- LazyVim ユーザーが Neovim を離れずに Outlook(デスクトップ版 / Classic Outlook)のメールを閲覧できる。
- 既存の LazyVim UI(snacks.picker, snacks.win, snacks.notify, which-key)に極力乗っかり、独自UIを最小化する。
- Neovim 側は「表示と操作のディスパッチ」に徹する薄いプラグインとし、Outlook とのやりとりは外部ヘルパープロセスに閉じ込める。
v1 の非ゴール(段階的に後回し)
- メール送信・返信・新規作成(Object Model Guard の送信プロンプト、下書き作成フローなど検討事項が多いため v2 送り)
- カレンダー/予定表の読み取り専用表示は
list_events+ almanac.nvim連携として実装済み(10節)。予定の新規作成・出欠回答・招待送信は引き続き非ゴール(送信を伴う操作はメール送信と同じ理由でv2以降に送る) - COM イベント(NewMailEx)によるリアルタイム通知 — v1 はポーリングのみ
- New Outlook (Web/Graphベースの新UI) 対応 — Classic Outlook 前提
┌─────────────────────────┐ stdin/stdout ┌──────────────────────────────┐
│ Neovim (Lua) │ 改行区切り JSON-RPC風 │ outlook-helper.ps1 │
│ lua/outlook/*.lua │ <──────────────────────> │ Windows PowerShell 5.1 (STA) │
│ - job管理・request/response│ │ New-Object -ComObject │
│ - snacks.picker / win │ │ Outlook.Application │
│ - which-key登録 │ │ 常駐・ポーリング │
└─────────────────────────┘ └───────────────┬──────────────┘
│ COM
┌──────▼───────┐
│ Outlook (Classic) │
└───────────────┘
- Python/pywin32 は不採用。ヘルパーは Windows PowerShell 5.1 (
powershell.exe) で実装し、Outlook COM オブジェクトモデルへNew-Object -ComObject Outlook.Applicationで直接アクセスする。- PowerShell 5.1 の既定ホストは STA アパートメントで起動するため、pywin32 で必要な
pythoncom.CoInitialize()相当の明示処理が不要。 - pwsh(PowerShell 7)はデフォルトMTAのため対象外。
powershell.exeを明示的に呼ぶ。 - Windows 同梱コンポーネントのみで完結するため、ユーザーは Python/pip の追加インストールが不要。
- PowerShell 5.1 の既定ホストは STA アパートメントで起動するため、pywin32 で必要な
- ヘルパーは 常駐プロセス。
jobstart(cmd, {rpc = false})で起動し、プラグインのライフサイクル中は起動しっぱなしにする(Outlook Dispatch/プロファイル読み込みのコストをリクエスト毎に払わないため)。 - 通信は 改行区切りJSON(LSP風の
Content-Lengthヘッダは使わず、1行1JSONのシンプルな形式)。vim.json.decode/encodeと PowerShell のConvertTo-Json -Compress/ConvertFrom-Jsonで完結する。
Neovim → ヘルパー(リクエスト、1行1JSON、\n 区切り):
{"id": 1, "method": "list_messages", "params": {"folder": "inbox", "limit": 50, "unread_only": false}}ヘルパー → Neovim(レスポンス):
{"id": 1, "ok": true, "result": {"items": [ ... ]}}{"id": 1, "ok": false, "error": {"code": "OUTLOOK_NOT_RUNNING", "message": "Could not connect to Outlook (it is not running)"}}idは Lua 側が発番するインクリメンタルな整数。Lua側はid -> callbackのテーブルで応答を待つ非同期リクエスト/レスポンスにする(on_stdoutはチャンク単位で来るので\nでバッファリングしてJSONをパースする)。- ヘルパー起点のプッシュ通知(将来の新着メール通知等)は
idなしの{"event": "new_mail", "data": {...}}形式を予約しておくが、v1では未使用(ポーリングのみ)。
| method | 用途 |
|---|---|
ping |
ヘルパー起動確認・Outlook接続確認 |
list_folders |
Inbox配下含むフォルダ一覧取得 |
list_messages |
指定フォルダのメール一覧(件名/差出人/受信日時/既読状態/EntryID) |
get_message |
1件の本文取得(EntryID+StoreID指定、Body優先・必要ならHTMLBody) |
mark_read / mark_unread |
既読状態変更 |
set_flag / clear_flag |
フォローアップフラグの設定/解除(MailItem.FlagStatus。v1はnone⇔flaggedの二値のみ、completeは未対応) |
search_messages |
Items.Restrict によるDASL/Jetフィルタ検索(差出人・件名・日付・未読) |
list_events |
カレンダーの予定一覧(10節。from/toはUnix epoch秒の範囲指定) |
各メソッドは PowerShell 側で Items.Sort("[ReceivedTime]", $true) → Restrict(...) の順で処理し、大量メールの全走査を避ける。一覧表示に必要な項目(件名/差出人/日時/既読/フラグ状態)のみ返し、本文は get_message で個別取得(遅延ロード)。
- 一覧・キャッシュのキーは
EntryID+StoreIDの組。Outlook 側で移動されるとEntryIDのみでは不安定なため、両方保持してGetItemFromID(entryId, storeId)で再取得する。
- Outlook 未起動時:
GetActiveObject("Outlook.Application")相当(PowerShellでは[Runtime.InteropServices.Marshal]::GetActiveObject)で先に既存インスタンスの有無を確認し、無ければ明確なエラーOUTLOOK_NOT_RUNNINGを返す(自動起動はしない — 起動に時間がかかる上、意図しないプロファイル起動を避ける)。 - New Outlook 判定: COM Dispatch 自体が失敗する、またはバージョンチェックで新UIと判定できた場合は
NEW_OUTLOOK_UNSUPPORTEDを返し、Classic Outlook への切り替えを促すメッセージを表示する。 - RPCサーバーが利用できません 等のCOMエラー: Outlook がクラッシュ/再起動された場合、ヘルパー側で
Application/Namespaceハンドルを次リクエスト時に再取得するリトライ処理を入れる。 - 文字コード: PowerShell と Neovim 間は UTF-8 前提。
$OutputEncoding/ コンソールコードページの設定をヘルパー起動スクリプトで明示する(日本語件名・本文の文字化け対策)。 - v1 は読み取り専用のため、送信系で問題になる Object Model Guard の送信確認プロンプトは発生しない想定(
SenderEmailAddressアクセス等、読み取り系の一部APIでもガードが発火するケースがあるとされるため、実装時に実機確認は必要)。
lua/outlook/
init.lua -- setup(opts) のみ。実体は config.lua に委譲(folke系プラグインの慣習)
config.lua -- defaults/options/extend()。lazy.nvim/snacks.nvim等と同じ形
health.lua -- :checkhealth outlook (Windows/powershell.exe/snacks.nvim有無を確認)
helper.lua -- jobstart管理, request(method, params, callback), 改行区切りJSONの送受信
picker.lua -- snacks.picker連携(一覧・アクション), 無ければ vim.ui.select にフォールバック
preview.lua -- snacks.win によるメール本文プレビュー/読み取りウィンドウ
commands.lua -- :Outlook系ユーザーコマンド定義
keymaps.lua -- <leader>m 配下のキーマップ + which-key group登録
calendar.lua -- almanac.nvim連携(10節)。almanac.nvim未導入時はno-op
- picker.lua:
snacks.nvimの有無で判定し、あればSnacks.picker.pickでメール一覧(件名/差出人/日時、未読は強調表示)+ プレビュー(既定は件名/差出人/日時のヘッダのみ。本文は含まない — 一覧取得時にBodyへアクセスしないための設計判断。3.2節参照)+ アクション(既読切替、本文をpreview.luaのウィンドウで開く、<C-l>でpicker内プレビューに本文を読み込む)を提供。無い環境ではvim.ui.select+ 別コマンドでの本文表示にデグレードする。<C-l>(本文をプレビューに読み込む):get_messageを呼び本文を取得して、preview()コールバックが直近に受け取ったctx(current_preview_ctxとして保持)へ直接書き込む。あくまでユーザーがキーを押した時だけ実行される明示操作であり、カーソル移動に連動した自動読み込みは行わない(一覧を素早くスクロールしただけでOutlook COMへget_messageが連打されるのを避けるため)。取得した本文はentry_idをキーにbody_cacheへ保持し、同一メッセージの再表示では再取得しない。取得成功時はopen_messageと同じ「未読なら既読にする」処理(fetch_and_mark_read)を共有する。
- preview.lua:
opts.message_windowで開き方を切り替える。既定"buffer"は、現在のウィンドウに読み取り専用の通常の listed バッファ(buflisted=true,bufhidden=hide)を開く方式で、フローティングでも強制split でもなく、他の(コードの)バッファと同格に<C-^>/:bnext/bufferline等で行き来できることを重視した設計(利用者からのフィードバックで、フローティング/split より通常バッファの方が読み物として自然という判断)。"float"にすると従来通りSnacks.win(無ければnvim_open_win)による読み取り専用フローティングウィンドウになる。いずれも本文は非編集バッファとして表示(bo.modifiable=false,bo.filetype="mail"等)。 - 通知(取得失敗、Outlook未起動等)は
Snacks.notify/ 無ければvim.notifyにフォールバック。
<leader>mを "mail" グループとして which-key に登録(opts.keys ~= falseの場合、keymaps.luaがwhich-key.addで登録)。<leader>mm: メール一覧を開く(picker)<leader>mu: 未読のみ一覧<leader>ms: 検索(vim.ui.inputで件名/差出人条件を受けてsearch_messages)- 既読/未読トグル専用の既定キーマップは持たない。メッセージを開く、または
<C-l>で本文を読み込むと自動的に既読になり(通常のメールクライアントのUXに合わせた設計判断)、snacks.picker使用時のみ picker 内<C-r>で明示トグルできる。<C-l>はpicker側の固定バインドでopts.keysの対象外。 - フォローアップフラグの設定/解除も同様に、
snacks.picker使用時のみ picker 内<C-f>でトグルできる(none⇔flagged)。専用コマンドは持たない。 <C-e>(もっと読み込む): 一覧・検索結果の件数上限を50件増やして再取得し、picker内のitemsを閉じずに差し替える。詳細・制約は6.2節。
- ユーザーコマンド:
:OutlookOpen,:OutlookRefresh(キャッシュ無視で再取得),:OutlookUnread,:OutlookSearch,:OutlookHelperRestart(ヘルパープロセスの再起動、デバッグ用)。
PowerShellプロセス起動 + Outlook COM接続には無視できない遅延があるため、以下で体感速度を確保する。
- 常駐ヘルパー:
helper.luaはプロセスを一度起動したら保持し、リクエスト毎の再起動を避ける。 - prewarm:
opts.prewarm = trueにするとsetup()実行時点でヘルパー起動+Outlook接続をバックグラウンドで開始する。lazy.nvim の読み込みトリガーをcmd/keys(=ユーザー操作まで未ロード)ではなくevent = "VeryLazy"等にしているユーザー向けのオプション。 - picker側の表示タイミング:
snacks.picker・vim.ui.selectいずれの経路でもpicker.list()はキャッシュ/helperから結果(またはエラー)が揃った時点で初めてUIを開く(pickerウィンドウを先に開いて後から非同期に流し込む方式ではない)。そのためキャッシュヒット時は体感即時、ミス時のみ「Outlook: loading…」を通知してから結果を待つ(キャッシュヒットではこの通知は出さない)。将来snacks.pickerの非同期finderに載せ替えて「pickerを即開いて後から流し込む」体験にする余地はあるが、実機で snacks API を確認できるまで見送っている。 - 短時間キャッシュ + 多重リクエスト抑止:
picker.luaが同一パラメータの結果をcache_ttl_ms(既定15秒)の間再利用し、同時に発火した同一リクエストは1本のOutlook COM呼び出しに集約する(:OutlookRefreshはキャッシュを無視して強制再取得)。
snacks.nvimのソース(lua/snacks/picker/core/finder.lua, lua/snacks/picker/core/picker.lua)を直接確認して設計した。
finderはフィルタ(検索クエリ文字列)が変わった時に1回だけ呼ばれる。非同期finder(コールバックで逐次add(item))も存在するが、これは「1回のfinder呼び出しの中で結果を少しずつ流し込む」ためのものであり、スクロール位置をトリガーに追加のfinder呼び出しが走る仕組みは無い。- 開いているpickerに項目を追加する公開APIは無い(
picker:add_items()のようなものは存在しない)。一方picker:find({ refresh = true })で finder を強制的に再実行でき、picker:refresh()は「選択をクリアし、finder/matcherを再実行する」と説明されている(refresh_row()で既読/フラグ変更後の行更新に使用。3.2節のConvertTo-MessageSummaryとは無関係)。 - Outlook COM の
Itemsコレクションには offset/cursor に相当するものが無く、Restrict/Sort後にforeachで先頭から数える以外の方法が無い(3.2節)。
以上から、「スクロールで自動的に追加取得される」形の無限スクロールは snacks.picker の設計と噛み合わない。実装した <C-e> は代わりに:
- 現在の
limitに50を足して同じmethod/paramsでlist_messages/search_messagesを再実行(=フォルダの先頭からlimit件を数え直すだけで、差分だけを安く取ってくるわけではない) - 結果を、
Snacks.picker.pick({items=...})に渡した同じテーブルオブジェクトの中身を丸ごと入れ替える形で反映(新しいテーブルに差し替えるとpicker内部の参照と食い違う可能性があるため) picker:refresh()をベストエフォートで呼び、その場での再描画を試みる
picker.luaのcurrent_list_state(module-level, 同時に開けるoutlook pickerは1つの前提)が{method, params, items, limit, loading}を保持し、M.showがopts.method/opts.paramsを受け取った時だけ有効化される(:OutlookSearchもこれに対応済み)。state.loadingで多重リクエストを防止し、取得件数が増えなかった場合は「これ以上のメールはありません」を通知する。
実機未確認事項: picker:refresh()が実際に「同じitemsテーブルの中身が変わっていること」を検知して再描画するか(finderが「テーブル」として渡された場合、再実行時に元のテーブル参照をipairsで再走査するのか、それとも初回にのみ内部へ取り込んで以降は無視するのか)は、この開発環境(snacks無し)では確認できていない。ダメだった場合の見た目上のフォールバックは「<C-e>を押しても件数だけ増えて表示が変わらず、picker を閉じて:OutlookOpenし直すと反映されている」という、今回の既読/フラグ表示更新と同種の症状になる想定。
- 対象環境: Windows + Outlook (Classic, デスクトップ版) + Windows PowerShell 5.1(標準搭載)。
- lazy.nvim ユーザー向けの通常プラグインスペックとして配布(LazyVim公式extrasへの登録はv1では狙わず、将来的に安定後に検討)。
- README に「LazyVimへの組み込み例」としてコピペ用の
lua/plugins/outlook.luaスペック片を掲載する(実質的にextra相当のUXを個人配布で提供)。
- 対象メールボックスが単一(既定プロファイル)前提か、複数アカウント/共有メールボックス対応も見るか。(v1は既定プロファイル1つのみ)
list_messagesのページング方式(件数指定のみ/日付レンジ/カーソルベース)をどうするか。(v1は件数上限のみ)- 本文表示は プレーンテキスト(
Body)のみで十分か、HTML本文の簡易テキスト化まで要るか。(v1はBodyのみ) ヘルパーの配置場所→ 解決済み: リポジトリ同梱のhelper/outlook-helper.ps1をそのまま-File指定で実行する(コピー不要)。lua/outlook/helper.luaがプラグイン自身のパスから相対的にスクリプトパスを解決する。→ 解決済み:Invoke-*の$null集約Invoke-GetMessage/Invoke-SetReadは対象アイテムが見つからない場合New-HelperError -Code "ITEM_NOT_FOUND"を返すようになり、$null(Outlook未接続)とは区別される。Get-FolderByName経由のフォルダ未検出(list_messages/search_messages)は現状も$null→OUTLOOK_NOT_RUNNINGに丸めたまま(v1が扱う3フォルダ名は固定なので実運用上は起こりにくい)。- Outlook側での既読状態変更(他クライアント/デバイスからの変更)を、この一覧キャッシュ(既定15秒)がどこまで許容して古いままにするかは実運用で様子見。
list_foldersは実装済みだが呼び出し元(コマンド/キーマップ)が無い未使用API。Get-FolderByNameがinbox/sent/draftsの既定フォルダ名しか解決できず、list_foldersが返す任意のpath(例"受信トレイ/プロジェクトA")からフォルダを引くには PS側にパス解決処理を追加する必要があるため、:OutlookFoldersのようなフォルダ選択コマンドの追加は本体スコープでは見送っている。追加する場合は Get-FolderByName の拡張とセットで行うこと。- フラグは v1 では
none⇔flaggedの二値トグルのみ(<C-f>)。FlagStatus = 1(olFlagComplete, 完了マーク)・FlagRequest("Follow up"以外のカスタム文言)・FlagDueBy(期限日時)は未対応。必要になればInvoke-SetFlagにオプション引数を足す形で拡張できる。 Invoke-ListEventsのRestrict日付フィルタ([Start] < 'g形式文字列' AND [End] > 'g形式文字列')は、Outlook COM自動化で広く使われる定石パターンだが、.ToString("g")の書式と Outlook側のロケール依存パーサが実機(日本語Windows等)で正しく噛み合うかは未検証。10節・docs/HANDOFF.mdの実機確認事項を参照。
- v1: 本ドキュメントのスコープ(閲覧・既読管理・検索)
- v1.1: カレンダー読み取り専用表示(
list_events+ almanac.nvim連携。10節) - v2: 下書き作成・送信(
.Display()優先でユーザー確認を挟む)、添付ファイル一覧/保存 - v3: 予定の新規作成・出欠回答、複数アカウント対応
10. カレンダー連携(almanac.nvim)
outlook.nvim自身はカレンダーUIを持たず(1節のゴール通り)、汎用の月/週/日カレンダーUIプラグインalmanac.nvimへOutlookの予定データを渡すだけの薄い変換層としてlua/outlook/calendar.luaを実装した。
M.provider(range, cb): almanac.nvimのEventProvider契約(非同期function(range, cb)形。almanac.nvim側 docs/DESIGN.md 3.2節)を満たす関数。range.from/range.to(epoch秒)をそのままlist_eventsのfrom/toに渡し、結果をalmanac.Event形式(id=entry_id,title=subject,data={entry_id=, store_id=})に変換する。- キャッシュ+多重リクエスト抑止(2026-07-25追加):
picker.luaのlist_messages/search_messagesと同じ設計をrange(from/to)キーで導入した。同一範囲への再取得はconfig.options.cache_ttl_ms(既定15秒)以内なら再利用し、同時に飛んだ同一範囲リクエストは1本化する。monthビューは前後の余白日を含め35〜42日分と範囲が広く、定例会議があればIncludeRecurrences展開で該当期間分のオカレンス取得というCOM側の実作業も増えるため、view切替や<C-f>/<C-b>で同じ範囲へ行き来するだけで毎回フル再取得が走っていたのを解消する狙い。実際の初回取得速度(COM呼び出し自体の速さ)は改善しないため、体感の改善は「行き来」のケースに限られる。取得中はnotify.info("Outlook: loading calendar…")をキャッシュヒット時を除いて表示する(mail一覧と同じ作法)。
- キャッシュ+多重リクエスト抑止(2026-07-25追加):
M.open()::OutlookCalendar(既定キー<leader>mc)から呼ばれ、almanac.Calendarインスタンスを(初回のみ)生成してevents = M.providerで紐付け、以降は使い回す(show()を呼ぶだけ)。almanac.nvimは任意の依存(pcall(require, "almanac")で判定)であり、未導入時は:OutlookCalendarが通知を出すだけで何も壊れない — 既存のsnacks.nvim任意依存と同じ作法。event_selectedハンドラ:open_message(メール本文の全文表示)と同じ作法で、選択した予定の詳細をget_appointment(Invoke-GetAppointment)で取得しlua/outlook/preview.luaのM.open_eventで表示する(opts.message_windowに従いbuffer/float、mail側と共通のウィンドウ管理コードを流用)。表示内容は件名・主催者・開始/終了(終日予定は日付のみ)・場所・ステータス(free/tentative/busy/out_of_office)・本文。- オンラインミーティングURL(
gm): 予定詳細バッファのgmキーで、本文中からオンラインミーティングの参加URLを探してvim.ui.open()で開く。classic Outlook COMには参加URLを直接返す信頼できるプロパティが無い(Graph APIのonlineMeeting.joinUrl相当は無い)ため、本文中のプレーンテキスト/ハイパーリンクを正規表現で走査する方式にした(lua/outlook/preview.luaのfind_meeting_url)。Teams/Zoom/Google Meet/Webex/GoToMeeting/Chime/Wherebyのドメインを優先的に拾い、該当が無ければ本文中最初のURLにフォールバックする。見つからなければ通知のみ。 - 定例会議のオカレンス解決:
IncludeRecurrences=$trueで展開された一覧上の各オカレンスは全て同じEntryIDを共有し、GetItemFromIDはシリーズのマスターを返す(Microsoft公式ドキュメントで確認済み — 10節末尾参照)。そのためevent_selectedは選択したイベント自身のstart(epoch秒)をoccurrence_startとしてget_appointmentに渡し、PS側(Invoke-GetAppointment)がAppointmentItem.IsRecurringかつoccurrence_startがある場合にRecurrencePattern.GetOccurrence(日付)でその日のオカレンス自体を解決してから件名/時刻/場所を返す(オカレンス単位の編集・例外にも対応)。該当日にオカレンスが見つからない場合(一覧取得後に削除された等)はマスターにフォールバックする。
- オンラインミーティングURL(
- 日時変換(PS側の
ConvertTo-UnixTime)は、OutlookのCOMが返すDateTime(Kindが信頼できない)を「ローカル時刻」とみなしTimeZoneInfo.LocalでUTC変換してからUnix epoch秒にする。almanac.nvim/Neovim側は一切タイムゾーンを意識しない(almanac.nvim docs/DESIGN.md 3.2の設計方針通り)。OutlookのAppointmentItem.Start/.Endが予定自体の保存タイムゾーンに関わらず常にOutlookプロファイルの現在のローカルタイムゾーンで表現されることをMicrosoft公式ドキュメントで確認済み(docs/HANDOFF.md 11節参照)。 - 実機未検証:
Invoke-ListEventsの日付範囲Restrictフィルタが実機のロケール設定と正しく噛み合うか、get_appointmentのオカレンス解決(RecurrencePattern.GetOccurrence)が定例会議で期待通り動くか、gmが実際のTeams/Webexリンクを正しく検出・起動できるかは、この開発環境(Windows/Outlookなし)では確認できていない。docs/HANDOFF.mdに検証項目を追加した。 - v4検討: 新着通知(ポーリング間隔短縮 or COMイベント化の再検討)