diff --git a/.tests/microsoft-outlook.test.ts b/.tests/microsoft-outlook.test.ts new file mode 100644 index 0000000..f1cba6b --- /dev/null +++ b/.tests/microsoft-outlook.test.ts @@ -0,0 +1,265 @@ +import { readFileSync } from 'node:fs'; +import { createContext, Script } from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +function readPluginFile(filename: string) { + return readFileSync(new URL(`../microsoft-outlook/${filename}`, import.meta.url), 'utf8'); +} + +function readManifest() { + return JSON.parse(readPluginFile('ghost.json')) as { + network: { + hosts: string[]; + secrets: Array<{ + key: string; + inject?: { hosts?: string[] }; + oauth?: { + authorizeUrl?: string; + tokenUrl?: string; + clientId?: string; + clientSecret?: string; + scopes?: string[]; + pkce?: boolean; + redirectPort?: number; + identity?: { url?: string; labelPath?: string }; + }; + }>; + }; + tools: Array<{ + name: string; + parameters?: { + properties?: { + action?: { enum?: string[] }; + }; + }; + }>; + }; +} + +function formatConnectError(error: string, detail: string) { + const source = readPluginFile('settings.js'); + const match = source.match( + /function connectError\(result\) \{([\s\S]*?)\n \}\n function render/, + ); + if (!match) throw new Error('microsoft-outlook does not declare connectError'); + + const context = createContext({ + input: { error, detail }, + output: '', + String, + }); + new Script( + `function connectError(result) {${match[1]}\n }\noutput = connectError(input);`, + { filename: 'microsoft-outlook/settings.js' }, + ).runInContext(context); + return context.output as string; +} + +type CindyFetchRequest = { + url: string; + method?: string; + headers?: Record; + body?: string; + authAccount?: string; +}; + +async function runOutlookTool( + args: Record, + response: { status: number; body: string }, +) { + const requests: CindyFetchRequest[] = []; + let handler: + | ((message: { + type: string; + tool: string; + args: Record; + callId: string; + }) => Promise) + | undefined; + let toolResult: Record | undefined; + const context = createContext({ + cindy: { + onHostMessage( + nextHandler: (message: { + type: string; + tool: string; + args: Record; + callId: string; + }) => Promise, + ) { + handler = nextHandler; + }, + async fetch(request: CindyFetchRequest) { + requests.push(request); + return { + ok: true, + status: response.status, + body: response.body, + }; + }, + send(result: Record) { + toolResult = result; + }, + }, + fetch: async () => { + throw new Error('unexpected settings fetch'); + }, + }); + new Script(readPluginFile('main.js'), { + filename: 'microsoft-outlook/main.js', + }).runInContext(context); + if (!handler) throw new Error('microsoft-outlook did not register a host message handler'); + + await handler({ + type: 'tool-call', + tool: 'outlook', + args, + callId: 'call-1', + }); + return { requests, toolResult }; +} + +describe('Microsoft Outlook OAuth 配置', () => { + it('使用 Filo Microsoft client、PKCE 和固定 loopback 回调', () => { + const manifest = readManifest(); + const secret = manifest.network.secrets.find((item) => item.key === 'outlook_account'); + const oauth = secret?.oauth; + + expect(oauth?.clientId).toBe('93f8508e-04c6-4c69-b707-8f5cd45b17c5'); + expect(oauth?.clientSecret).toBeUndefined(); + expect(oauth?.pkce).toBe(true); + expect(oauth?.redirectPort).toBe(53683); + expect(oauth?.authorizeUrl).toBe( + 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + ); + expect(oauth?.tokenUrl).toBe( + 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + ); + }); + + it('只向 Microsoft Graph 注入 OAuth token,并声明所需邮箱权限', () => { + const manifest = readManifest(); + const secret = manifest.network.secrets.find((item) => item.key === 'outlook_account'); + const oauth = secret?.oauth; + + expect(manifest.network.hosts).toEqual( + expect.arrayContaining(['login.microsoftonline.com', 'graph.microsoft.com']), + ); + expect(secret?.inject?.hosts).toEqual(['graph.microsoft.com']); + expect(oauth?.identity?.url).toContain('https://graph.microsoft.com/v1.0/me'); + expect(oauth?.identity?.labelPath).toBe('userPrincipalName'); + expect(oauth?.scopes).toEqual( + expect.arrayContaining(['offline_access', 'User.Read', 'Mail.ReadWrite', 'Mail.Send']), + ); + }); + + it('暴露与首版邮箱范围一致的动作', () => { + const manifest = readManifest(); + const tool = manifest.tools.find((item) => item.name === 'outlook'); + + expect(tool?.parameters?.properties?.action?.enum).toEqual([ + 'search', + 'read', + 'send', + 'draft', + 'mark_read', + 'mark_unread', + 'move', + 'list_folders', + ]); + }); + + it('设置页给出可行动的 Microsoft OAuth 错误', () => { + const message = formatConnectError('EXCHANGE_FAILED', 'redirect_uri mismatch'); + expect(message).toContain('Microsoft token 交换失败'); + expect(message).toContain('Entra 应用的桌面回调配置'); + expect(message).toContain('redirect_uri mismatch'); + }); + + it('search 调用 Microsoft Graph 并返回归一化邮件摘要', async () => { + const { requests, toolResult } = await runOutlookTool( + { action: 'search', query: 'invoice', max_results: 3, account: 'account-1' }, + { + status: 200, + body: JSON.stringify({ + value: [ + { + id: 'message-1', + conversationId: 'conversation-1', + subject: 'Invoice', + from: { emailAddress: { address: 'sender@example.com' } }, + toRecipients: [{ emailAddress: { address: 'me@example.com' } }], + receivedDateTime: '2026-07-24T00:00:00Z', + bodyPreview: 'Please review', + isRead: false, + hasAttachments: true, + importance: 'high', + }, + ], + }), + }, + ); + + expect(requests).toHaveLength(1); + expect(requests[0].authAccount).toBe('account-1'); + const url = new URL(requests[0].url); + expect(url.origin).toBe('https://graph.microsoft.com'); + expect(url.pathname).toBe('/v1.0/me/messages'); + expect(url.searchParams.get('$search')).toBe('"invoice"'); + expect(url.searchParams.get('$top')).toBe('3'); + expect(toolResult).toMatchObject({ + type: 'tool-result', + callId: 'call-1', + ok: true, + result: { + messages: [ + { + id: 'message-1', + from: 'sender@example.com', + subject: 'Invoice', + is_read: false, + has_attachments: true, + }, + ], + }, + }); + }); + + it('send 只把用户明确提供的邮件提交到 Graph sendMail', async () => { + const { requests, toolResult } = await runOutlookTool( + { + action: 'send', + to: 'a@example.com, b@example.com', + cc: 'copy@example.com', + subject: 'Hello', + body_text: 'Body', + }, + { status: 202, body: '' }, + ); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + url: 'https://graph.microsoft.com/v1.0/me/sendMail', + method: 'POST', + }); + expect(JSON.parse(requests[0].body ?? '{}')).toEqual({ + message: { + subject: 'Hello', + body: { contentType: 'Text', content: 'Body' }, + toRecipients: [ + { emailAddress: { address: 'a@example.com' } }, + { emailAddress: { address: 'b@example.com' } }, + ], + ccRecipients: [{ emailAddress: { address: 'copy@example.com' } }], + bccRecipients: [], + }, + saveToSentItems: true, + }); + expect(toolResult).toMatchObject({ + type: 'tool-result', + callId: 'call-1', + ok: true, + result: { sent: true }, + }); + }); +}); diff --git a/README.md b/README.md index de5aeb6..cffe1c8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ are no longer bundled with the desktop app as submodules or seeded at startup. | Notion | [`cindy-notion`](./cindy-notion) | Read/write Notion pages, databases, and knowledge bases | | Web Search | [`cindy-web-search`](./cindy-web-search) | Public web search (Brave / Tavily, user-provided API key) | | 163 Mail | [`163-mail`](./163-mail) | Search, read, organize, compose, and send 163 Mail via IMAP/SMTP | +| Microsoft Outlook | [`microsoft-outlook`](./microsoft-outlook) | Connect Outlook independently to search, read, organize, draft, and send mail | | QQ Mail | [`qq-mail`](./qq-mail) | Cindy stores the authorization code securely; search, read, organize, and send via IMAP/SMTP on demand | | TapTap Maker | [`taptap-maker`](./taptap-maker) | Account connection, project sync, builds, and official news tools | diff --git a/README.zh-CN.md b/README.zh-CN.md index d589644..e7e636d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,6 +19,7 @@ submodule 随桌面端打包或在启动时播种。 | Notion | [`cindy-notion`](./cindy-notion) | Notion 页面、数据库与知识库读写 | | Web Search | [`cindy-web-search`](./cindy-web-search) | 公网搜索(Brave / Tavily,用户自备 API key) | | 163 邮箱 | [`163-mail`](./163-mail) | 通过 IMAP/SMTP 搜索、阅读、整理、撰写和发送 163 邮箱邮件 | +| Microsoft Outlook | [`microsoft-outlook`](./microsoft-outlook) | 独立连接 Outlook,搜索、阅读、整理、起草和发送邮件 | | QQ 邮箱 | [`qq-mail`](./qq-mail) | Cindy 安全保存授权码,按需通过 IMAP/SMTP 搜索、阅读、整理和发送 | | TapTap Maker | [`taptap-maker`](./taptap-maker) | 账号连接、项目同步、构建与官方动态工具 | diff --git a/microsoft-outlook/assets/icon.png b/microsoft-outlook/assets/icon.png new file mode 100644 index 0000000..7f94ef2 Binary files /dev/null and b/microsoft-outlook/assets/icon.png differ diff --git a/microsoft-outlook/ghost.json b/microsoft-outlook/ghost.json new file mode 100644 index 0000000..b089fd3 --- /dev/null +++ b/microsoft-outlook/ghost.json @@ -0,0 +1,133 @@ +{ + "schemaVersion": 2, + "id": "microsoft-outlook", + "name": "Outlook", + "description": "独立连接 Microsoft Outlook:搜索、阅读、整理邮件并生成草稿或发送邮件。授权与令牌由 Cindy 托管。", + "whenToUse": "需要查 Outlook 邮件、读取邮件正文、整理邮件、生成草稿或发送邮件时使用。请先在本插件详情页单独连接 Microsoft 账号。", + "version": "1.0.0", + "locales": { + "en": "locales/en.json", + "zh-CN": "locales/zh-CN.json", + "ja": "locales/ja.json", + "ko": "locales/ko.json" + }, + "author": "Cindy", + "icon": "assets/icon.png", + "entry": "main.js", + "settingsHtml": "settings.html", + "slots": ["tool", "network"], + "command": "microsoft-outlook", + "network": { + "hosts": [ + "login.microsoftonline.com", + "graph.microsoft.com" + ], + "secrets": [ + { + "key": "outlook_account", + "label": "Outlook 账号", + "source": "oauth", + "hint": "只授权 Outlook 邮箱权限;到本插件详情页点「连接账号」完成授权", + "inject": { + "header": "Authorization", + "format": "Bearer {value}", + "hosts": ["graph.microsoft.com"] + }, + "oauth": { + "authorizeUrl": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "tokenUrl": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "clientId": "93f8508e-04c6-4c69-b707-8f5cd45b17c5", + "scopes": [ + "openid", + "profile", + "email", + "offline_access", + "User.Read", + "Mail.ReadWrite", + "Mail.Send" + ], + "pkce": true, + "redirectPort": 53683, + "extraAuthorizeParams": { + "prompt": "select_account" + }, + "identity": { + "url": "https://graph.microsoft.com/v1.0/me?$select=userPrincipalName,mail,displayName,id", + "labelPath": "userPrincipalName" + } + } + } + ] + }, + "tools": [ + { + "name": "outlook_accounts", + "description": "列出本插件已连接的 Microsoft Outlook 账号及状态。账号必须先在 Outlook 插件详情页单独授权。", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "outlook", + "description": "执行 Outlook 邮箱操作:search 搜索邮件,read 读取正文,send 发送邮件,draft 保存草稿,mark_read/mark_unread 修改已读状态,move 移动邮件,list_folders 列出文件夹。发送和整理动作必须由用户明确表达。", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "search", + "read", + "send", + "draft", + "mark_read", + "mark_unread", + "move", + "list_folders" + ] + }, + "account": { + "type": "string", + "description": "账号 id;省略时使用本插件默认账号" + }, + "query": { + "type": "string", + "description": "search 使用的纯文本关键词或短语" + }, + "max_results": { + "type": "number", + "description": "search 返回条数,默认 5,最大 10" + }, + "message_id": { + "type": "string", + "description": "read、mark_read、mark_unread、move 使用的邮件 id" + }, + "destination_folder_id": { + "type": "string", + "description": "move 的目标文件夹 id;先调用 list_folders 获取" + }, + "to": { + "type": "string", + "description": "send/draft 收件人,多个用逗号分隔" + }, + "cc": { + "type": "string", + "description": "send/draft 抄送人,多个用逗号分隔" + }, + "bcc": { + "type": "string", + "description": "send/draft 密送人,多个用逗号分隔" + }, + "subject": { + "type": "string" + }, + "body_text": { + "type": "string" + } + }, + "required": ["action"] + } + } + ] +} diff --git a/microsoft-outlook/locales/en.json b/microsoft-outlook/locales/en.json new file mode 100644 index 0000000..55825e4 --- /dev/null +++ b/microsoft-outlook/locales/en.json @@ -0,0 +1,13 @@ +{ + "name": "Outlook", + "description": "Connect Microsoft Outlook independently to search, read, and organize messages, and create drafts or send mail. Cindy manages authorization and tokens.", + "whenToUse": "Use when the user needs to search Outlook mail, read message bodies, organize messages, create drafts, or send mail. Connect a Microsoft account separately in this plugin's details first.", + "tools": { + "outlook_accounts": { + "description": "List the Microsoft Outlook accounts connected to this plugin and their status. Each account must first be authorized in the Outlook plugin details." + }, + "outlook": { + "description": "Perform Outlook operations: search messages, read message bodies, send mail, save drafts, mark messages read or unread, move messages, and list folders. Sending and organization actions require explicit user intent." + } + } +} diff --git a/microsoft-outlook/locales/ja.json b/microsoft-outlook/locales/ja.json new file mode 100644 index 0000000..fed2ea4 --- /dev/null +++ b/microsoft-outlook/locales/ja.json @@ -0,0 +1,13 @@ +{ + "name": "Outlook", + "description": "Microsoft Outlookへ個別に接続し、メールの検索、閲覧、整理、下書き作成、送信を行います。認可とトークンはCindyが管理します。", + "whenToUse": "Outlookメールの検索、本文の閲覧、整理、下書き作成、または送信が必要な場合に使用します。最初にこのプラグインの詳細画面でMicrosoftアカウントを個別に接続してください。", + "tools": { + "outlook_accounts": { + "description": "このプラグインに接続済みのMicrosoft Outlookアカウントと状態を一覧表示します。各アカウントは先にOutlookプラグインの詳細画面で認可する必要があります。" + }, + "outlook": { + "description": "Outlookでメール検索、本文閲覧、送信、下書き保存、既読・未読の変更、メール移動、フォルダー一覧を実行します。送信や整理操作にはユーザーの明確な指示が必要です。" + } + } +} diff --git a/microsoft-outlook/locales/ko.json b/microsoft-outlook/locales/ko.json new file mode 100644 index 0000000..37a6e3a --- /dev/null +++ b/microsoft-outlook/locales/ko.json @@ -0,0 +1,13 @@ +{ + "name": "Outlook", + "description": "Microsoft Outlook에 별도로 연결하여 메일을 검색하고 읽고 정리하며, 임시보관 메일을 만들거나 메일을 보냅니다. 인증과 토큰은 Cindy가 관리합니다.", + "whenToUse": "Outlook 메일 검색, 본문 읽기, 메일 정리, 임시보관 또는 보내기가 필요할 때 사용합니다. 먼저 이 플러그인 상세 화면에서 Microsoft 계정을 별도로 연결하세요.", + "tools": { + "outlook_accounts": { + "description": "이 플러그인에 연결된 Microsoft Outlook 계정과 상태를 표시합니다. 각 계정은 먼저 Outlook 플러그인 상세 화면에서 인증해야 합니다." + }, + "outlook": { + "description": "Outlook에서 메일 검색, 본문 읽기, 보내기, 임시보관, 읽음·읽지 않음 표시, 메일 이동, 폴더 목록 조회를 수행합니다. 보내기와 정리 작업에는 사용자의 명확한 요청이 필요합니다." + } + } +} diff --git a/microsoft-outlook/locales/zh-CN.json b/microsoft-outlook/locales/zh-CN.json new file mode 100644 index 0000000..bf02c8e --- /dev/null +++ b/microsoft-outlook/locales/zh-CN.json @@ -0,0 +1,13 @@ +{ + "name": "Outlook", + "description": "独立连接 Microsoft Outlook:搜索、阅读、整理邮件并生成草稿或发送邮件。授权与令牌由 Cindy 托管。", + "whenToUse": "需要查 Outlook 邮件、读取邮件正文、整理邮件、生成草稿或发送邮件时使用。请先在本插件详情页单独连接 Microsoft 账号。", + "tools": { + "outlook_accounts": { + "description": "列出本插件已连接的 Microsoft Outlook 账号及状态。账号必须先在 Outlook 插件详情页单独授权。" + }, + "outlook": { + "description": "执行 Outlook 邮箱操作:search 搜索邮件,read 读取正文,send 发送邮件,draft 保存草稿,mark_read/mark_unread 修改已读状态,move 移动邮件,list_folders 列出文件夹。发送和整理动作必须由用户明确表达。" + } + } +} diff --git a/microsoft-outlook/main.js b/microsoft-outlook/main.js new file mode 100644 index 0000000..e8c2f50 --- /dev/null +++ b/microsoft-outlook/main.js @@ -0,0 +1,393 @@ +/* global cindy */ + +var SECRET_KEY = 'outlook_account'; +var PLUGIN_NAME = 'Outlook'; +var BASE = 'https://graph.microsoft.com/v1.0/me'; +var IMMUTABLE_ID_PREFER = 'IdType="ImmutableId"'; + +function fail(message) { + return { ok: false, message: message }; +} + +function clampInt(value, fallback, max) { + var n = typeof value === 'number' && isFinite(value) ? Math.floor(value) : fallback; + return Math.min(max, Math.max(1, n)); +} + +function errorMessage(data, fallback) { + if (data && data.error && data.error.message) return data.error.message; + return fallback; +} + +async function api(opts) { + var request = { + url: opts.url, + method: opts.method || 'GET', + headers: { + Accept: 'application/json', + Prefer: opts.prefer || IMMUTABLE_ID_PREFER, + }, + callId: opts.callId, + }; + if (opts.account) request.authAccount = opts.account; + if (opts.consistencyLevel) request.headers.ConsistencyLevel = opts.consistencyLevel; + if (opts.body !== undefined) { + request.headers['Content-Type'] = 'application/json'; + request.body = JSON.stringify(opts.body); + } + var response = await cindy.fetch(request); + if (!response.ok) return { err: response.message }; + + var data = null; + if (response.body) { + try { + data = JSON.parse(response.body); + } catch (_err) { + return { err: 'Microsoft 返回了无法解析的响应(HTTP ' + response.status + ')' }; + } + } + if (response.status < 200 || response.status >= 300) { + return { + err: 'Microsoft Graph 返回 HTTP ' + response.status + ':' + + errorMessage(data, (response.body || '').slice(0, 200)), + }; + } + return { data: data }; +} + +async function listAccounts() { + var response = await fetch('/oauth'); + if (!response.ok) return fail('账号状态查询失败(' + response.status + ')'); + var list = await response.json(); + var entry = list.find(function (item) { return item && item.key === SECRET_KEY; }); + if (!entry || !entry.clientConfigured) { + return fail('内置应用身份缺失,请升级 Cindy 后重试'); + } + if (!entry.accounts.length) { + return fail('尚未连接 Microsoft 账号,请到「' + PLUGIN_NAME + '」详情页单独授权'); + } + return { + ok: true, + result: { + accounts: entry.accounts.map(function (account) { + return { + id: account.id, + email: account.label, + status: account.status, + is_default: account.isDefault, + }; + }), + }, + }; +} + +function emailAddress(value) { + return value && value.emailAddress ? value.emailAddress.address || '' : ''; +} + +function emailAddresses(values) { + return (Array.isArray(values) ? values : []).map(emailAddress).filter(Boolean); +} + +function recipientList(value, fieldName) { + if (value === undefined || value === null || value === '') { + return { recipients: [] }; + } + if (/[\r\n]/.test(String(value))) { + return { error: fieldName + ' 不得包含换行符' }; + } + var addresses = String(value) + .split(',') + .map(function (item) { return item.trim(); }) + .filter(Boolean); + if (!addresses.length) return { error: fieldName + ' 不能为空' }; + return { + recipients: addresses.map(function (address) { + return { emailAddress: { address: address } }; + }), + }; +} + +function stripHtml(value) { + return String(value || '') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(//gi, '\n') + .replace(/<\/p\s*>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/[ \t]{2,}/g, ' ') + .trim(); +} + +function messageSummary(message) { + return { + id: message.id, + conversation_id: message.conversationId || '', + from: emailAddress(message.from), + to: emailAddresses(message.toRecipients), + subject: message.subject || '', + date: message.receivedDateTime || message.sentDateTime || '', + snippet: message.bodyPreview || '', + is_read: !!message.isRead, + has_attachments: !!message.hasAttachments, + importance: message.importance || 'normal', + folder_id: message.parentFolderId || '', + web_link: message.webLink || '', + }; +} + +function buildMessage(args) { + var to = recipientList(args.to, 'to'); + if (to.error) return { error: to.error }; + if (!to.recipients.length) return { error: 'to 不能为空' }; + var cc = recipientList(args.cc, 'cc'); + if (cc.error) return { error: cc.error }; + var bcc = recipientList(args.bcc, 'bcc'); + if (bcc.error) return { error: bcc.error }; + if (/[\r\n]/.test(String(args.subject))) { + return { error: 'subject 不得包含换行符' }; + } + return { + message: { + subject: String(args.subject), + body: { + contentType: 'Text', + content: String(args.body_text), + }, + toRecipients: to.recipients, + ccRecipients: cc.recipients, + bccRecipients: bcc.recipients, + }, + }; +} + +async function outlook(args, callId) { + var account = args.account; + + if (args.action === 'search') { + var query = String(args.query || '').trim(); + if (!query) return fail('search 需要 query(纯文本关键词或短语)'); + var escapedQuery = '"' + query.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; + var select = [ + 'id', + 'conversationId', + 'subject', + 'from', + 'toRecipients', + 'receivedDateTime', + 'sentDateTime', + 'bodyPreview', + 'isRead', + 'hasAttachments', + 'importance', + 'parentFolderId', + 'webLink', + ].join(','); + var listed = await api({ + url: BASE + '/messages?$search=' + encodeURIComponent(escapedQuery) + + '&$top=' + clampInt(args.max_results, 5, 10) + + '&$select=' + encodeURIComponent(select), + account: account, + callId: callId, + consistencyLevel: 'eventual', + }); + if (listed.err) return fail(listed.err); + var messages = ((listed.data && listed.data.value) || []).map(messageSummary); + return { + ok: true, + result: { + messages: messages, + has_more: !!(listed.data && listed.data['@odata.nextLink']), + }, + }; + } + + if (args.action === 'read') { + if (!args.message_id) return fail('read 需要 message_id'); + var selectRead = [ + 'id', + 'conversationId', + 'subject', + 'from', + 'toRecipients', + 'ccRecipients', + 'bccRecipients', + 'receivedDateTime', + 'sentDateTime', + 'body', + 'bodyPreview', + 'isRead', + 'hasAttachments', + 'importance', + 'categories', + 'parentFolderId', + 'webLink', + ].join(','); + var full = await api({ + url: BASE + '/messages/' + encodeURIComponent(args.message_id) + + '?$select=' + encodeURIComponent(selectRead), + account: account, + callId: callId, + prefer: IMMUTABLE_ID_PREFER + ', outlook.body-content-type="text"', + }); + if (full.err) return fail(full.err); + var content = full.data && full.data.body ? full.data.body.content || '' : ''; + if (full.data && full.data.body && String(full.data.body.contentType).toLowerCase() === 'html') { + content = stripHtml(content); + } + if (content.length > 20000) content = content.slice(0, 20000) + '\n…(正文过长已截断)'; + return { + ok: true, + result: { + id: full.data.id, + conversation_id: full.data.conversationId || '', + from: emailAddress(full.data.from), + to: emailAddresses(full.data.toRecipients), + cc: emailAddresses(full.data.ccRecipients), + bcc: emailAddresses(full.data.bccRecipients), + subject: full.data.subject || '', + date: full.data.receivedDateTime || full.data.sentDateTime || '', + body: content, + is_read: !!full.data.isRead, + has_attachments: !!full.data.hasAttachments, + importance: full.data.importance || 'normal', + categories: full.data.categories || [], + folder_id: full.data.parentFolderId || '', + web_link: full.data.webLink || '', + }, + }; + } + + if (args.action === 'list_folders') { + var folders = await api({ + url: BASE + '/mailFolders?includeHiddenFolders=true&$top=100&$select=' + + encodeURIComponent( + 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount,isHidden', + ), + account: account, + callId: callId, + }); + if (folders.err) return fail(folders.err); + return { + ok: true, + result: { + folders: ((folders.data && folders.data.value) || []).map(function (folder) { + return { + id: folder.id, + name: folder.displayName, + parent_folder_id: folder.parentFolderId || '', + child_folder_count: folder.childFolderCount || 0, + total_count: folder.totalItemCount || 0, + unread_count: folder.unreadItemCount || 0, + is_hidden: !!folder.isHidden, + }; + }), + }, + }; + } + + if (args.action === 'mark_read' || args.action === 'mark_unread') { + if (!args.message_id) return fail(args.action + ' 需要 message_id'); + var isRead = args.action === 'mark_read'; + var marked = await api({ + url: BASE + '/messages/' + encodeURIComponent(args.message_id), + method: 'PATCH', + body: { isRead: isRead }, + account: account, + callId: callId, + }); + if (marked.err) return fail(marked.err); + return { ok: true, result: { modified: true, id: args.message_id, is_read: isRead } }; + } + + if (args.action === 'move') { + if (!args.message_id || !args.destination_folder_id) { + return fail('move 需要 message_id 和 destination_folder_id'); + } + var moved = await api({ + url: BASE + '/messages/' + encodeURIComponent(args.message_id) + '/move', + method: 'POST', + body: { destinationId: String(args.destination_folder_id) }, + account: account, + callId: callId, + }); + if (moved.err) return fail(moved.err); + return { + ok: true, + result: { + moved: true, + id: moved.data && moved.data.id ? moved.data.id : args.message_id, + destination_folder_id: args.destination_folder_id, + }, + }; + } + + if (args.action === 'send' || args.action === 'draft') { + if (!args.to || args.subject === undefined || args.body_text === undefined) { + return fail(args.action + ' 需要 to / subject / body_text'); + } + var built = buildMessage(args); + if (built.error) return fail(built.error); + if (args.action === 'send') { + var sent = await api({ + url: BASE + '/sendMail', + method: 'POST', + body: { message: built.message, saveToSentItems: true }, + account: account, + callId: callId, + }); + if (sent.err) return fail(sent.err); + return { ok: true, result: { sent: true } }; + } + var draft = await api({ + url: BASE + '/messages', + method: 'POST', + body: built.message, + account: account, + callId: callId, + }); + if (draft.err) return fail(draft.err); + return { + ok: true, + result: { + draft: true, + id: draft.data.id, + web_link: draft.data.webLink || '', + }, + }; + } + + return fail('未知 action:' + args.action); +} + +cindy.onHostMessage(async function (message) { + if (!message || message.type !== 'tool-call') return; + try { + var result = message.tool === 'outlook_accounts' + ? await listAccounts() + : message.tool === 'outlook' + ? await outlook(message.args || {}, message.callId) + : fail('未知工具:' + message.tool); + if (result.ok) { + cindy.send({ type: 'tool-result', callId: message.callId, ok: true, result: result.result }); + } else { + cindy.send({ type: 'tool-result', callId: message.callId, ok: false, message: result.message }); + } + } catch (error) { + cindy.send({ + type: 'tool-result', + callId: message.callId, + ok: false, + message: 'Outlook 工具执行失败:' + + (error && error.message ? error.message : String(error)), + }); + } +}); diff --git a/microsoft-outlook/settings.css b/microsoft-outlook/settings.css new file mode 100644 index 0000000..253779f --- /dev/null +++ b/microsoft-outlook/settings.css @@ -0,0 +1,14 @@ +* { box-sizing: border-box; margin: 0; } +body { padding: 2px 0 0; color: var(--text-primary, #1a1a1a); font: 13px/1.5 system-ui, sans-serif; } +.title { display: flex; align-items: center; gap: 8px; font-weight: 500; } +.badge { padding: 1px 8px; border-radius: 6px; font-size: 11px; font-weight: 400; background: var(--surface-chip, #efefec); color: var(--text-tertiary, #8a8a86); } +.hint, #status { margin-top: 6px; font-size: 11px; color: var(--text-tertiary, #8a8a86); } +.account { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 6px 12px; border: 1px solid var(--border-default, #e4e4e0); border-radius: 10px; } +.email { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tag { font-size: 11px; color: var(--text-tertiary, #8a8a86); } +.expired { color: var(--error-fg, #b53333); } +button { padding: 4px 12px; border-radius: 999px; border: 1px solid var(--border-default, #e4e4e0); background: transparent; color: inherit; cursor: pointer; font: inherit; } +button.primary { margin-top: 10px; background: var(--accent-cta-bg, #262626); color: var(--accent-pure-cta-fg, #fff); border-color: transparent; } +button:disabled { opacity: .5; cursor: default; } +#accounts:empty::after { content: '尚未连接账号'; display: block; margin-top: 8px; font-size: 11px; color: var(--text-tertiary, #8a8a86); } +#status:empty { display: none; } diff --git a/microsoft-outlook/settings.html b/microsoft-outlook/settings.html new file mode 100644 index 0000000..0765064 --- /dev/null +++ b/microsoft-outlook/settings.html @@ -0,0 +1,15 @@ + + + + + + + +
Outlook 账号 独立授权
+

只授权 Outlook 邮箱权限,不会同时取得 OneDrive、Teams 或 SharePoint 权限。

+
+ +

+ + + diff --git a/microsoft-outlook/settings.js b/microsoft-outlook/settings.js new file mode 100644 index 0000000..d6a0587 --- /dev/null +++ b/microsoft-outlook/settings.js @@ -0,0 +1,93 @@ +(function () { + 'use strict'; + var KEY = 'outlook_account'; + var LABEL = 'Microsoft'; + var $ = function (id) { return document.getElementById(id); }; + function status(text) { $('status').textContent = text; } + function connectError(result) { + var labels = { + NO_CLIENT_CONFIG: '插件缺少 OAuth 客户端配置,请更新插件', + INVALID_CONFIG: 'OAuth 配置无效,请更新插件', + CALLBACK_INVALID: '授权回调校验失败,请重试', + EXCHANGE_FAILED: 'Microsoft token 交换失败,请检查 Entra 应用的桌面回调配置', + NETWORK: '连接 Microsoft 失败,请检查网络后重试', + TIMEOUT: '授权等待超时,请重试', + CANCELLED: '授权已取消', + ACCOUNT_LIMIT: '已达到账号数量上限', + VAULT_WRITE_FAILED: '账号保存失败,请重试', + }; + var code = result && result.error ? String(result.error) : ''; + var message = labels[code] || '连接失败,请重试'; + var detail = result && result.detail ? String(result.detail).trim() : ''; + return detail ? message + '(' + detail + ')' : message; + } + function render(entry) { + var box = $('accounts'); + box.textContent = ''; + ((entry && entry.accounts) || []).forEach(function (account) { + var row = document.createElement('div'); + row.className = 'account'; + var email = document.createElement('span'); + email.className = 'email'; + email.textContent = account.label || account.id; + row.appendChild(email); + var tag = document.createElement('span'); + tag.className = 'tag' + (account.status === 'expired' ? ' expired' : ''); + tag.textContent = account.status === 'expired' ? '需重新连接' : account.isDefault ? '默认' : ''; + row.appendChild(tag); + if (!account.isDefault && account.status !== 'expired') { + var makeDefault = document.createElement('button'); + makeDefault.textContent = '设为默认'; + makeDefault.onclick = function () { + void fetch('/oauth/' + KEY + '/default', { + method: 'POST', + body: JSON.stringify({ accountId: account.id }), + }).then(load); + }; + row.appendChild(makeDefault); + } + var disconnect = document.createElement('button'); + disconnect.textContent = '断开'; + disconnect.onclick = function () { + void fetch('/oauth/' + KEY + '/accounts/' + encodeURIComponent(account.id), { + method: 'DELETE', + }).then(load); + }; + row.appendChild(disconnect); + box.appendChild(row); + }); + } + async function load() { + try { + var response = await fetch('/oauth'); + if (!response.ok) throw new Error('HTTP ' + response.status); + var list = await response.json(); + if (!Array.isArray(list)) throw new Error('invalid response'); + render(list.find(function (item) { return item && item.key === KEY; })); + } catch (_err) { + render(null); + status('账号状态加载失败,请重试'); + } + } + async function connect() { + $('connect').disabled = true; + status('已打开浏览器,请完成 ' + LABEL + ' 授权…'); + try { + var response = await fetch('/oauth/' + KEY + '/connect', { method: 'POST' }); + if (!response.ok) throw new Error('HTTP ' + response.status); + var result = await response.json(); + if (result.ok) { + status('已连接 ' + (result.account && result.account.label ? result.account.label : '账号')); + } else { + status(connectError(result)); + } + await load(); + } catch (_err) { + status('连接失败,请重试'); + } finally { + $('connect').disabled = false; + } + } + $('connect').onclick = function () { void connect(); }; + void load(); +})(); diff --git a/provisioning.json b/provisioning.json index 24df4f5..37fb666 100644 --- a/provisioning.json +++ b/provisioning.json @@ -31,6 +31,9 @@ "qq-mail": { "audience": "all" }, + "microsoft-outlook": { + "audience": "all" + }, "taptap-maker": { "audience": "all" },