📧
OutlookアドインからLLMを呼び出す方法 - Office.jsとFastAPIで実現するAI連携
はじめに
こんにちは!
株式会社エクスプラザのhyodoです!
Office製品に独自のAIを組み込みたいと思ったことはありませんか?
今回は、OutlookアドインからLLM(GPT-3.5-turbo)を呼び出す仕組みを利用して、メール本文の宛名と送信先メールアドレスのドメインを検証するアドインを実装しました。Office.js APIとFastAPIを使って、OutlookとOpenAI APIをシームレスに連携させる方法を解説します。
なぜOutlookアドインからLLMを呼び出すのか
Microsoft 365には標準でAI機能(Copilot)が搭載されつつありますが、以下のようなケースでは独自実装が必要です:
- 組織独自のプロンプトでLLMを使いたい
- 特定の業務ロジック(宛名とドメインの照合など)に特化したAI処理を実装したい
- カスタムモデルや異なるLLMプロバイダーを使いたい
今回作成したアドイン
このアドインでは、LLMを使って以下のようなご送信を防ぐためにメール本文の宛名と送信先アドレスのドメインを検証しています。
❌ 誤送信例
本文: 「エクスプラザ様」
送信先: user@yahoo.co.jp
→ LLMが不一致を検出して送信をブロック
✅ 正常例
本文: 「株式会社エクスプラザ様」
送信先: user@explaza.jp
→ カタカナとローマ字でもLLMが同じ会社と判定
アーキテクチャ
技術スタック
- フロントエンド: TypeScript + Office.js API
- バックエンド: Python + FastAPI + litellm
- LLM: OpenAI GPT-3.5-turbo
- 通信: HTTPS + CORS
サンプルコード
プロジェクト構造
outlook-llm-addon/
├── backend/
│ ├── main.py
│ ├── requirements.txt
│ └── .env
├── src/
│ ├── taskpane/
│ │ ├── taskpane.html
│ │ └── taskpane.js
│ └── runtime/
│ └── runtime.js
├── manifest.xml
├── package.json
└── webpack.config.js
1. バックエンド実装(backend/main.py)
backend/main.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import litellm
from typing import List, Dict, Optional
import os
from dotenv import load_dotenv
import json
# 環境変数を読み込む
load_dotenv()
app = FastAPI()
# CORS設定(Outlookアドインからのアクセスを許可)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://localhost:3000", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 環境変数からAPIキーを取得して設定
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not OPENAI_API_KEY:
raise ValueError("OPENAI_API_KEY is not set in environment variables")
# 環境変数として設定(litellmはos.environから読み取る)
os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY
class Recipient(BaseModel):
email: str
name: Optional[str] = ""
type: Optional[str] = "To"
class AnalyzeRequest(BaseModel):
recipients: List[Recipient]
body: Optional[str] = ""
subject: Optional[str] = ""
@app.get("/")
async def root():
return {"message": "Outlook LLM Backend is running"}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.post("/analyze-with-llm")
def analyze_with_llm(request: AnalyzeRequest):
try:
# プロンプトの構築
system_prompt = """
あなたはメール分析の専門家です。
与えられたメールの情報を分析し、以下の形式のJSONで結果を返してください:
{
"risk_level": "low/medium/high",
"risk_reason": "リスクの理由",
"suggestions": ["改善提案1", "改善提案2"],
"summary": "全体的な分析結果"
}
"""
# 宛先を種類別に整理
to_emails = [r.email for r in request.recipients if r.type == "To"]
cc_emails = [r.email for r in request.recipients if r.type == "Cc"]
user_prompt = f"""
以下のメール情報を分析してください:
宛先(To): {', '.join(to_emails)}
宛先(Cc): {', '.join(cc_emails)}
件名: {request.subject}
本文(最初の500文字): {request.body[:500]}
特に以下の点に注意して分析してください:
1. 宛先の適切性(社内外の混在、誤送信の可能性)
2. 本文と宛先の整合性
3. 機密情報の漏洩リスク
4. 一般的なビジネスマナー
JSON形式で回答してください。
"""
# LLM呼び出し
response = litellm.completion(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
temperature=0.1,
max_tokens=500
)
# レスポンスの解析
llm_result = response.choices[0].message.content
# JSONとしてパース(エラーハンドリング付き)
try:
analysis = json.loads(llm_result)
except json.JSONDecodeError:
# パースできない場合のフォールバック
analysis = {
"risk_level": "medium",
"risk_reason": "分析結果の解析に失敗しました",
"suggestions": ["メールを再確認してください"],
"summary": llm_result
}
# usageオブジェクトを取得(辞書形式の場合もある)
usage = response.get('usage', {}) if isinstance(response, dict) else response.usage
if hasattr(usage, 'prompt_tokens'):
# 属性としてアクセス可能な場合
prompt_tokens = usage.prompt_tokens
completion_tokens = usage.completion_tokens
total_tokens = usage.total_tokens
else:
# 辞書としてアクセスする場合
prompt_tokens = usage.get('prompt_tokens', 0)
completion_tokens = usage.get('completion_tokens', 0)
total_tokens = usage.get('total_tokens', 0)
return {
"status": "success",
"analysis": analysis,
"model": response.model if hasattr(response, 'model') else response.get('model', 'gpt-3.5-turbo'),
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"estimated_cost": total_tokens * 0.000002 # GPT-3.5の料金
}
}
except Exception as e:
print(f"Error in analyze_with_llm: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
2. フロントエンド実装(src/taskpane/taskpane.js)
src/taskpane/taskpane.js
Office.onReady((info) => {
if (info.host === Office.HostType.Outlook) {
document.getElementById("analyzeButton").onclick = analyzeEmail;
}
});
async function analyzeEmail() {
try {
showLoading(true);
updateStatus("メール情報を取得中...");
// メールデータを取得
const mailData = await getMailData();
updateStatus("AIで分析中...");
// LLMで分析
const result = await callLLMAPI(mailData);
// 結果を表示
displayResults(result);
} catch (error) {
console.error("Analysis failed:", error);
showError(`エラーが発生しました: ${error.message}`);
} finally {
showLoading(false);
}
}
async function getMailData() {
const item = Office.context.mailbox.item;
// 宛先情報を取得
const toRecipients = await getRecipientsAsync(item.to);
const ccRecipients = await getRecipientsAsync(item.cc);
const bccRecipients = await getRecipientsAsync(item.bcc);
// 本文を取得
const body = await new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Text, (result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value || "");
} else {
resolve("");
}
});
});
// 件名を取得(非同期)
const subject = await new Promise((resolve) => {
if (item.subject && typeof item.subject === 'string') {
resolve(item.subject);
} else if (item.subject && item.subject.getAsync) {
item.subject.getAsync((result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value || "");
} else {
resolve("");
}
});
} else {
resolve("");
}
});
return {
recipients: [
...toRecipients.map(r => ({ ...r, type: "To" })),
...ccRecipients.map(r => ({ ...r, type: "Cc" })),
...bccRecipients.map(r => ({ ...r, type: "Bcc" }))
],
body: body || "",
subject: subject || ""
};
}
function getRecipientsAsync(recipients) {
return new Promise((resolve) => {
if (!recipients) {
resolve([]);
return;
}
recipients.getAsync((result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value.map(r => ({
email: r.emailAddress,
name: r.displayName
})));
} else {
resolve([]);
}
});
});
}
async function callLLMAPI(mailData) {
const response = await fetch("http://localhost:8000/analyze-with-llm", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(mailData)
});
if (!response.ok) {
const error = await response.text();
throw new Error(`API Error: ${error}`);
}
return await response.json();
}
function displayResults(result) {
const resultDiv = document.getElementById("results");
const analysis = result.analysis;
// リスクレベルに応じた色を設定
const riskColors = {
low: "#4CAF50",
medium: "#FF9800",
high: "#F44336"
};
resultDiv.innerHTML = `
<div style="padding: 20px; background-color: #f5f5f5; border-radius: 8px;">
<h3>AI分析結果</h3>
<div style="margin-bottom: 15px;">
<strong>リスクレベル:</strong>
<span style="background-color: ${riskColors[analysis.risk_level]};
color: white; padding: 5px 10px; border-radius: 4px;">
${analysis.risk_level.toUpperCase()}
</span>
</div>
<div style="margin-bottom: 15px;">
<strong>理由:</strong>
<p>${analysis.risk_reason}</p>
</div>
<div style="margin-bottom: 15px;">
<strong>改善提案:</strong>
<ul>
${analysis.suggestions.map(s => `<li>${s}</li>`).join('')}
</ul>
</div>
<div style="margin-bottom: 15px;">
<strong>分析サマリー:</strong>
<p>${analysis.summary}</p>
</div>
<div style="font-size: 12px; color: #666;">
<strong>使用モデル:</strong> ${result.model}<br>
<strong>トークン数:</strong> ${result.usage.total_tokens}<br>
<strong>推定コスト:</strong> $${result.usage.estimated_cost.toFixed(4)}
</div>
</div>
`;
}
function showLoading(show) {
document.getElementById("loading").style.display = show ? "block" : "none";
document.getElementById("analyzeButton").disabled = show;
}
function updateStatus(message) {
document.getElementById("status").textContent = message;
}
function showError(message) {
const resultDiv = document.getElementById("results");
resultDiv.innerHTML = `
<div style="padding: 20px; background-color: #ffebee; border-radius: 8px; color: #c62828;">
<strong>エラー:</strong> ${message}
</div>
`;
}
3. タスクペインHTML(src/taskpane/taskpane.html)
src/taskpane/taskpane.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Outlook LLM Analyzer</title>
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f8f9fa;
}
.container {
max-width: 100%;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #0078d4;
font-size: 24px;
margin-bottom: 20px;
}
button {
background-color: #0078d4;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
border-radius: 4px;
cursor: pointer;
width: 100%;
margin-bottom: 20px;
}
button:hover {
background-color: #106ebe;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
#loading {
display: none;
text-align: center;
margin: 20px 0;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #0078d4;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#status {
text-align: center;
color: #666;
margin: 10px 0;
}
#results {
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>📧 メールAI分析</h1>
<p>このメールをAIが分析し、潜在的なリスクや改善点を提案します。</p>
<button id="analyzeButton">メールを分析する</button>
<div id="loading">
<div class="spinner"></div>
<p id="status">処理中...</p>
</div>
<div id="results"></div>
</div>
<script src="taskpane.js"></script>
</body>
</html>
4. Event-based Activation(src/runtime/runtime.js)
src/runtime/runtime.js
Office.initialize = function () {
console.log("Runtime initialized");
};
// 新規メール作成時のハンドラー
function onNewMessageComposeHandler(event) {
console.log("新規メール作成を検出しました");
try {
// 通知バーに情報を表示
Office.context.mailbox.item.notificationMessages.addAsync(
"autoCheckNotification",
{
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
message: "LLM宛先確認アドインが有効です。送信前に宛先を自動チェックします。",
icon: "icon-16",
persistent: true
}
);
} catch (error) {
console.error("onNewMessageComposeHandler error:", error);
}
event.completed();
}
// 送信時の自動チェック
async function onMessageSendHandler(event) {
try {
// メールデータを取得
const item = Office.context.mailbox.item;
const recipients = await getAllRecipients(item);
const body = await getBodyText(item);
// 件名を取得(文字列として)
let subject = "";
try {
if (typeof item.subject === 'string') {
subject = item.subject;
}
} catch (e) {
console.error("Subject retrieval error:", e);
}
// LLMで分析
const response = await fetch("http://localhost:8000/analyze-with-llm", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
recipients,
body,
subject
})
});
if (!response.ok) {
throw new Error("API call failed");
}
const result = await response.json();
const analysis = result.analysis;
// リスクレベルに応じて送信可否を決定
if (analysis.risk_level === "high") {
event.completed({
allowEvent: false,
errorMessage: `⚠️ 高リスクが検出されました\n\n${analysis.risk_reason}\n\n改善提案:\n${analysis.suggestions.join('\n')}`
});
} else if (analysis.risk_level === "medium") {
// 中リスクの場合は警告のみ(送信は許可)
event.completed({
allowEvent: true,
errorMessage: `⚠️ 注意: ${analysis.risk_reason}`
});
} else {
// 低リスクの場合はそのまま送信
event.completed({ allowEvent: true });
}
} catch (error) {
console.error("Send check error:", error);
// エラー時は送信を許可(業務を止めない)
event.completed({
allowEvent: true,
errorMessage: "AI分析に失敗しました。注意して送信してください。"
});
}
}
async function getAllRecipients(item) {
const recipients = [];
const toRecipients = await getRecipientsAsync(item.to);
const ccRecipients = await getRecipientsAsync(item.cc);
const bccRecipients = await getRecipientsAsync(item.bcc);
recipients.push(...toRecipients.map(r => ({ ...r, type: "To" })));
recipients.push(...ccRecipients.map(r => ({ ...r, type: "Cc" })));
recipients.push(...bccRecipients.map(r => ({ ...r, type: "Bcc" })));
return recipients;
}
function getRecipientsAsync(recipients) {
return new Promise((resolve) => {
if (!recipients) {
resolve([]);
return;
}
recipients.getAsync((result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value.map(r => ({
email: r.emailAddress,
name: r.displayName
})));
} else {
resolve([]);
}
});
});
}
function getBodyText(item) {
return new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Text, (result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value || "");
} else {
resolve("");
}
});
});
}
// 関数を登録
Office.actions.associate("onNewMessageComposeHandler", onNewMessageComposeHandler);
Office.actions.associate("onMessageSendHandler", onMessageSendHandler);
5. マニフェストファイル(manifest.xml)の主要部分
manifest.xml
<?xml version="1.0" encoding="UTF-8"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0"
xsi:type="MailApp">
<Id>12345678-1234-1234-1234-123456789012</Id>
<Version>1.0.0.0</Version>
<ProviderName>Your Company</ProviderName>
<DefaultLocale>ja-JP</DefaultLocale>
<DisplayName DefaultValue="Outlook LLM Analyzer"/>
<Description DefaultValue="OutlookメールをAIで分析します"/>
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
<SupportUrl DefaultValue="https://www.example.com/help"/>
<AppDomains>
<AppDomain>https://localhost:3000</AppDomain>
<AppDomain>http://localhost:8000</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox"/>
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.10"/>
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
<RequestedHeight>450</RequestedHeight>
</DesktopSettings>
</Form>
<Form xsi:type="ItemEdit">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<Requirements>
<bt:Sets DefaultMinVersion="1.10">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<Runtimes>
<Runtime resid="Runtime.Url">
<Override type="javascript" resid="Runtime.Url"/>
</Runtime>
</Runtimes>
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<ExtensionPoint xsi:type="LaunchEvent">
<LaunchEvents>
<LaunchEvent Type="OnNewMessageCompose" FunctionName="onNewMessageComposeHandler"/>
<LaunchEvent Type="OnMessageSend" FunctionName="onMessageSendHandler"/>
</LaunchEvents>
<SourceLocation resid="Runtime.Url"/>
</ExtensionPoint>
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
<SupportsPinning>true</SupportsPinning>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
<bt:Url id="Runtime.Url" DefaultValue="https://localhost:3000/runtime.js"/>
</bt:Urls>
<bt:ShortStrings>
<bt:String id="GroupLabel" DefaultValue="AI分析"/>
<bt:String id="ButtonLabel" DefaultValue="メールを分析"/>
<bt:String id="ButtonTitle" DefaultValue="AI分析"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="メールを分析"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="ButtonDesc" DefaultValue="メールをAIで分析してリスクをチェックします"/>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="LLMを使用してメールの宛先を確認します"/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides>
</OfficeApp>
セットアップ手順
1. バックエンドのセットアップ
# バックエンドディレクトリに移動
cd backend
# 仮想環境を作成
python -m venv venv
# 仮想環境を有効化
.\venv\Scripts\activate
# 必要なパッケージをインストール
pip install fastapi uvicorn litellm python-dotenv
# .envファイルを作成(PowerShellで)
# 重要: UTF-8ではなくASCIIエンコーディングで作成
"OPENAI_API_KEY=sk-your-api-key-here" | Set-Content -Path .env -Encoding ASCII
# APIキーが正しく設定されているか確認
python -c "from dotenv import load_dotenv; import os; load_dotenv(); print('APIキー設定:', 'OK' if os.getenv('OPENAI_API_KEY') else 'NG')"
# サーバーを起動
python main.py
2. フロントエンドのセットアップ
npm init -y
# 必要なパッケージをインストール
npm install --save-dev webpack webpack-cli webpack-dev-server
npm install --save-dev html-webpack-plugin copy-webpack-plugin html-loader
npm install --save-dev office-addin-cli office-addin-dev-certs
npm install --save-dev @types/office-js
# 開発サーバーを起動
npm run start
3. Outlookへのインストール
- Outlookを開く
- リボンにあるアプリ(アドイン)→ アドインを取得
- 個人用アドイン → カスタムアドインの追加 → ファイルから追加
- manifest.xmlを選択 → インストール
まとめ
最後までお読みいだだきありがとうございました!
このコードをベースに、組織のニーズに合わせたカスタマイズを行っていただければと思います!
ご質問やフィードバックがあれば、コメント欄でお待ちしています!
参考

「プロダクトの力で、豊かな暮らしをつくる」をミッションに、法人向けに生成AIのPoC、コンサルティング〜開発を支援する事業を展開しております。 エンジニア募集しています。カジュアル面談応募はこちらから: herp.careers/careers/companies/explaza
Discussion