@symerian/symi 2.1.4 → 2.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/canvas-host/a2ui/.bundle.hash +1 -1
  3. package/dist/plugin-sdk/{accounts-BToL3HlP.js → accounts-BtaOa4z_.js} +1 -1
  4. package/dist/plugin-sdk/{accounts-D9zGZU5t.js → accounts-Ddm33hQm.js} +3 -3
  5. package/dist/plugin-sdk/{accounts-Dtszw3Zn.js → accounts-s-AdhXVR.js} +1 -1
  6. package/dist/plugin-sdk/{active-listener-bEk__wbB.js → active-listener-BXYeALs0.js} +1 -1
  7. package/dist/plugin-sdk/{agent-scope-C3gMMKCU.js → agent-scope-CYYpcO9W.js} +2 -2
  8. package/dist/plugin-sdk/{api-key-rotation-B7wX2cS9.js → api-key-rotation-BNN7DLRF.js} +1 -1
  9. package/dist/plugin-sdk/{audio-preflight-ni0fpVp8.js → audio-preflight-Cx5FPiJc.js} +24 -24
  10. package/dist/plugin-sdk/{bindings-BbwoUGPx.js → bindings-C7hRtgYW.js} +2 -2
  11. package/dist/plugin-sdk/{channel-activity-Ji7f0gqq.js → channel-activity-DoC1xtDu.js} +1 -1
  12. package/dist/plugin-sdk/{channel-web-DLpcsfAm.js → channel-web-Bz7z_PrR.js} +22 -22
  13. package/dist/plugin-sdk/{chrome-B3sE4bXC.js → chrome-BMG9BEuT.js} +3 -3
  14. package/dist/plugin-sdk/{chunk-jvk9axTQ.js → chunk-Dw2XBYXv.js} +1 -1
  15. package/dist/plugin-sdk/{command-format-DSdvQ_M5.js → command-format-GKSevep4.js} +1 -1
  16. package/dist/plugin-sdk/{commands-registry-DFVoW5Za.js → commands-registry-B6QKgH1z.js} +4 -4
  17. package/dist/plugin-sdk/{config-C8bdNl-t.js → config-ChoJh2v6.js} +9 -9
  18. package/dist/plugin-sdk/{deliver-DupqKEgT.js → deliver-PHupXW27.js} +10 -10
  19. package/dist/plugin-sdk/{diagnostic-mFf4i4G9.js → diagnostic-05pm5Rxi.js} +1 -1
  20. package/dist/plugin-sdk/{image-t0--fcO_.js → image-CZeY-Q2T.js} +4 -4
  21. package/dist/plugin-sdk/{image-ops-Bnp6LXEx.js → image-ops-BlQR__MN.js} +1 -1
  22. package/dist/plugin-sdk/index.js +53 -53
  23. package/dist/plugin-sdk/{ir-Fb3qpcis.js → ir-BJ6BHE5b.js} +4 -4
  24. package/dist/plugin-sdk/{local-roots-Ckk1QfzI.js → local-roots-BHLNSI8U.js} +3 -3
  25. package/dist/plugin-sdk/{login-YiSLGJpw.js → login-WrHy4q1W.js} +7 -7
  26. package/dist/plugin-sdk/{login-qr-Ct3iRCTA.js → login-qr-D30S2gk7.js} +9 -9
  27. package/dist/plugin-sdk/{manager-PXN_BUWT.js → manager-Cn2S7u-y.js} +8 -8
  28. package/dist/plugin-sdk/{manifest-registry-B3ugY9-f.js → manifest-registry-CPnHl_K3.js} +1 -1
  29. package/dist/plugin-sdk/{markdown-tables-Dfaqilz6.js → markdown-tables-BoYFajMu.js} +1 -1
  30. package/dist/plugin-sdk/{message-channel-BdI5Ra9S.js → message-channel-COTAJzHd.js} +1 -1
  31. package/dist/plugin-sdk/{model-selection-UQwTqGSw.js → model-selection-CPEL3Uc_.js} +4 -4
  32. package/dist/plugin-sdk/{outbound-attachment-DnVQfTG2.js → outbound-attachment-CnslKL38.js} +2 -2
  33. package/dist/plugin-sdk/{outbound-BaAisH4u.js → outbound-ps5sAOfw.js} +7 -7
  34. package/dist/plugin-sdk/{pi-auth-json-DAttpBPg.js → pi-auth-json-D4gyc_V3.js} +5 -5
  35. package/dist/plugin-sdk/{pi-embedded-helpers-CHlXbJGj.js → pi-embedded-helpers-CU4D77Op.js} +17 -17
  36. package/dist/plugin-sdk/{plugins-BbAvhC25.js → plugins-BNByVCIH.js} +4 -4
  37. package/dist/plugin-sdk/{pw-ai-JO8xac-v.js → pw-ai-81tOC-Ch.js} +8 -8
  38. package/dist/plugin-sdk/{qmd-manager-mjKcdwVr.js → qmd-manager-CH0XbIHf.js} +4 -4
  39. package/dist/plugin-sdk/{registry--_pGht6S.js → registry-D0xTnUWt.js} +2 -2
  40. package/dist/plugin-sdk/{replies-CVSgwFjl.js → replies-zmY9bbot.js} +3 -3
  41. package/dist/plugin-sdk/{reply-GcVMmckw.js → reply-BSikbH6_.js} +78 -78
  42. package/dist/plugin-sdk/{reply-prefix-BHuV5t70.js → reply-prefix-uxfMZW4p.js} +1 -1
  43. package/dist/plugin-sdk/{resolve-outbound-target-BkCUbYGV.js → resolve-outbound-target-BiyAyTWz.js} +2 -2
  44. package/dist/plugin-sdk/{resolve-route-D3JH_D2N.js → resolve-route-B3CCBumQ.js} +3 -3
  45. package/dist/plugin-sdk/{retry-ilSJqnz9.js → retry-CwQ_iIj8.js} +1 -1
  46. package/dist/plugin-sdk/{runner-Dm_yIGe6.js → runner-Co_eR7xp.js} +9 -9
  47. package/dist/plugin-sdk/{send-C9rT2n2M.js → send-6DwkdgEF.js} +7 -7
  48. package/dist/plugin-sdk/{send-BEeCC5ku.js → send-BNZVvZHE.js} +6 -6
  49. package/dist/plugin-sdk/{send-DNkOLgrC.js → send-D6BxZPP0.js} +6 -6
  50. package/dist/plugin-sdk/{send-DWr9Drgp.js → send-D8vgGF3T.js} +10 -10
  51. package/dist/plugin-sdk/{send-mQr5eXnb.js → send-Dw4gTEN6.js} +10 -10
  52. package/dist/plugin-sdk/{session-CQ5dJOsX.js → session-BTN5wyhB.js} +4 -4
  53. package/dist/plugin-sdk/{skill-commands-27tIhSRP.js → skill-commands-BUVE0jBL.js} +5 -5
  54. package/dist/plugin-sdk/{skills-B1GeRYlu.js → skills-_yTP47Cd.js} +7 -7
  55. package/dist/plugin-sdk/{sqlite-Cq_7Cg4E.js → sqlite-CxAR5ttJ.js} +1 -1
  56. package/dist/plugin-sdk/{store-Do3t33-c.js → store-BdrNabcU.js} +2 -2
  57. package/dist/plugin-sdk/{subsystem-Coz2AgU8.js → subsystem-B2uDN3TV.js} +1 -1
  58. package/dist/plugin-sdk/{tables-DR0NmBeH.js → tables-DNwXwNFa.js} +1 -1
  59. package/dist/plugin-sdk/{target-errors-B7YyMnIi.js → target-errors-Paro1BjP.js} +2 -2
  60. package/dist/plugin-sdk/{thinking-DCNUIAHY.js → thinking-CXqf7WTe.js} +5 -5
  61. package/dist/plugin-sdk/{tokens-CWMflosr.js → tokens-bC3UVmVH.js} +1 -1
  62. package/dist/plugin-sdk/{tool-images-D7Lno-TE.js → tool-images-HJ2sfZDV.js} +2 -2
  63. package/dist/plugin-sdk/{tool-loop-detection-edmW8ZiF.js → tool-loop-detection-BVA6fax-.js} +2 -2
  64. package/dist/plugin-sdk/web-eAue9zIL.js +65 -0
  65. package/dist/plugin-sdk/{whatsapp-actions-ZlyPpuJN.js → whatsapp-actions-DU3CTVCg.js} +21 -21
  66. package/extensions/outlook/index.ts +208 -0
  67. package/extensions/outlook/package.json +15 -0
  68. package/extensions/outlook/src/auth.ts +369 -0
  69. package/extensions/outlook/src/graph-mail.ts +150 -0
  70. package/extensions/outlook/src/store.ts +72 -0
  71. package/extensions/outlook/src/tools.ts +256 -0
  72. package/extensions/outlook/symi.plugin.json +11 -0
  73. package/package.json +1 -1
  74. package/dist/plugin-sdk/web-CJWupzDH.js +0 -65
@@ -0,0 +1,369 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { isWSL2Sync } from "symi/plugin-sdk";
4
+
5
+ // Microsoft OAuth 2.0 endpoints (multi-tenant: any Microsoft account)
6
+ const AUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
7
+ const TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
8
+ const REDIRECT_URI = "http://localhost:51122/oauth-callback";
9
+
10
+ // Scopes for Outlook mail access + offline refresh
11
+ const SCOPES = [
12
+ "https://graph.microsoft.com/Mail.Read",
13
+ "https://graph.microsoft.com/Mail.Send",
14
+ "https://graph.microsoft.com/Mail.ReadWrite",
15
+ "https://graph.microsoft.com/User.Read",
16
+ "offline_access",
17
+ ];
18
+
19
+ const RESPONSE_PAGE = `<!DOCTYPE html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="utf-8" />
23
+ <title>Symi Outlook OAuth</title>
24
+ <style>
25
+ body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
26
+ align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
27
+ main { text-align: center; padding: 2rem; background: white;
28
+ border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
29
+ h1 { color: #0078d4; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <main>
34
+ <h1>Outlook connected</h1>
35
+ <p>You can return to the terminal.</p>
36
+ </main>
37
+ </body>
38
+ </html>`;
39
+
40
+ // Symi's registered multi-tenant Azure AD app (public client, no secret needed).
41
+ // Users can override with SYMI_OUTLOOK_CLIENT_ID if they have their own app registration.
42
+ const DEFAULT_CLIENT_ID = "e24f680b-3fdd-4bce-a201-6bf547fe4735";
43
+
44
+ function resolveClientId(): string {
45
+ return process.env.SYMI_OUTLOOK_CLIENT_ID?.trim() || DEFAULT_CLIENT_ID;
46
+ }
47
+
48
+ export function generatePkce(): { verifier: string; challenge: string } {
49
+ const verifier = randomBytes(32).toString("hex");
50
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
51
+ return { verifier, challenge };
52
+ }
53
+
54
+ export function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
55
+ if (isRemote || isWSL2Sync()) {
56
+ return true;
57
+ }
58
+ // Headless Linux (no display server) — can't open a browser
59
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ export function buildAuthUrl(params: { challenge: string; state: string }): string {
66
+ const clientId = resolveClientId();
67
+ const url = new URL(AUTH_URL);
68
+ url.searchParams.set("client_id", clientId);
69
+ url.searchParams.set("response_type", "code");
70
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
71
+ url.searchParams.set("scope", SCOPES.join(" "));
72
+ url.searchParams.set("code_challenge", params.challenge);
73
+ url.searchParams.set("code_challenge_method", "S256");
74
+ url.searchParams.set("state", params.state);
75
+ url.searchParams.set("prompt", "consent");
76
+ return url.toString();
77
+ }
78
+
79
+ export function parseCallbackInput(
80
+ input: string,
81
+ ): { code: string; state: string } | { error: string } {
82
+ const trimmed = input.trim();
83
+ if (!trimmed) {
84
+ return { error: "No input provided" };
85
+ }
86
+ try {
87
+ const url = new URL(trimmed);
88
+ const code = url.searchParams.get("code");
89
+ const state = url.searchParams.get("state");
90
+ if (!code) {
91
+ return { error: "Missing 'code' parameter in URL" };
92
+ }
93
+ if (!state) {
94
+ return { error: "Missing 'state' parameter in URL" };
95
+ }
96
+ return { code, state };
97
+ } catch {
98
+ return { error: "Paste the full redirect URL (not just the code)." };
99
+ }
100
+ }
101
+
102
+ export async function startCallbackServer(params: { timeoutMs: number }) {
103
+ const redirect = new URL(REDIRECT_URI);
104
+ const port = redirect.port ? Number(redirect.port) : 51122;
105
+
106
+ let settled = false;
107
+ let resolveCallback: (url: URL) => void;
108
+ let rejectCallback: (err: Error) => void;
109
+
110
+ const callbackPromise = new Promise<URL>((resolve, reject) => {
111
+ resolveCallback = (url) => {
112
+ if (settled) return;
113
+ settled = true;
114
+ resolve(url);
115
+ };
116
+ rejectCallback = (err) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ reject(err);
120
+ };
121
+ });
122
+
123
+ const timeout = setTimeout(() => {
124
+ rejectCallback(new Error("Timed out waiting for OAuth callback"));
125
+ }, params.timeoutMs);
126
+ timeout.unref?.();
127
+
128
+ const server = createServer((request, response) => {
129
+ if (!request.url) {
130
+ response.writeHead(400, { "Content-Type": "text/plain" });
131
+ response.end("Missing URL");
132
+ return;
133
+ }
134
+
135
+ const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
136
+ if (url.pathname !== redirect.pathname) {
137
+ response.writeHead(404, { "Content-Type": "text/plain" });
138
+ response.end("Not found");
139
+ return;
140
+ }
141
+
142
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
143
+ response.end(RESPONSE_PAGE);
144
+ resolveCallback(url);
145
+
146
+ setImmediate(() => {
147
+ server.close();
148
+ });
149
+ });
150
+
151
+ await new Promise<void>((resolve, reject) => {
152
+ const onError = (err: Error) => {
153
+ server.off("error", onError);
154
+ reject(err);
155
+ };
156
+ server.once("error", onError);
157
+ server.listen(port, "127.0.0.1", () => {
158
+ server.off("error", onError);
159
+ resolve();
160
+ });
161
+ });
162
+
163
+ return {
164
+ waitForCallback: () => callbackPromise,
165
+ close: () =>
166
+ new Promise<void>((resolve) => {
167
+ server.close(() => resolve());
168
+ }),
169
+ };
170
+ }
171
+
172
+ export async function exchangeCode(params: {
173
+ code: string;
174
+ verifier: string;
175
+ }): Promise<{ access: string; refresh: string; expires: number }> {
176
+ const clientId = resolveClientId();
177
+ const response = await fetch(TOKEN_URL, {
178
+ method: "POST",
179
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
180
+ body: new URLSearchParams({
181
+ client_id: clientId,
182
+ code: params.code,
183
+ grant_type: "authorization_code",
184
+ redirect_uri: REDIRECT_URI,
185
+ code_verifier: params.verifier,
186
+ }),
187
+ });
188
+
189
+ if (!response.ok) {
190
+ const text = await response.text();
191
+ throw new Error(`Token exchange failed: ${text}`);
192
+ }
193
+
194
+ const data = (await response.json()) as {
195
+ access_token?: string;
196
+ refresh_token?: string;
197
+ expires_in?: number;
198
+ };
199
+
200
+ const access = data.access_token?.trim();
201
+ const refresh = data.refresh_token?.trim();
202
+ const expiresIn = data.expires_in ?? 0;
203
+
204
+ if (!access) {
205
+ throw new Error("Token exchange returned no access_token");
206
+ }
207
+ if (!refresh) {
208
+ throw new Error("Token exchange returned no refresh_token (ensure offline_access scope)");
209
+ }
210
+
211
+ // 5-minute buffer before actual expiry (same as google-antigravity)
212
+ const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
213
+ return { access, refresh, expires };
214
+ }
215
+
216
+ export async function refreshAccessToken(refreshToken: string): Promise<{
217
+ access: string;
218
+ refresh: string;
219
+ expires: number;
220
+ }> {
221
+ const clientId = resolveClientId();
222
+ const response = await fetch(TOKEN_URL, {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
225
+ body: new URLSearchParams({
226
+ client_id: clientId,
227
+ grant_type: "refresh_token",
228
+ refresh_token: refreshToken,
229
+ scope: SCOPES.join(" "),
230
+ }),
231
+ });
232
+
233
+ if (!response.ok) {
234
+ const text = await response.text();
235
+ throw new Error(`Token refresh failed: ${text}`);
236
+ }
237
+
238
+ const data = (await response.json()) as {
239
+ access_token?: string;
240
+ refresh_token?: string;
241
+ expires_in?: number;
242
+ };
243
+
244
+ const access = data.access_token?.trim();
245
+ // Microsoft may or may not rotate the refresh token
246
+ const refresh = data.refresh_token?.trim() ?? refreshToken;
247
+ const expiresIn = data.expires_in ?? 0;
248
+
249
+ if (!access) {
250
+ throw new Error("Token refresh returned no access_token");
251
+ }
252
+
253
+ const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
254
+ return { access, refresh, expires };
255
+ }
256
+
257
+ export async function fetchUserProfile(accessToken: string): Promise<{
258
+ email?: string;
259
+ displayName?: string;
260
+ }> {
261
+ try {
262
+ const response = await fetch("https://graph.microsoft.com/v1.0/me", {
263
+ headers: { Authorization: `Bearer ${accessToken}` },
264
+ });
265
+ if (!response.ok) {
266
+ return {};
267
+ }
268
+ const data = (await response.json()) as {
269
+ mail?: string;
270
+ userPrincipalName?: string;
271
+ displayName?: string;
272
+ };
273
+ return {
274
+ email: data.mail || data.userPrincipalName,
275
+ displayName: data.displayName,
276
+ };
277
+ } catch {
278
+ return {};
279
+ }
280
+ }
281
+
282
+ export async function loginOutlook(params: {
283
+ isRemote: boolean;
284
+ openUrl: (url: string) => Promise<void>;
285
+ prompt: (message: string) => Promise<string>;
286
+ note: (message: string, title?: string) => Promise<void>;
287
+ log: (message: string) => void;
288
+ progress: { update: (msg: string) => void; stop: (msg?: string) => void };
289
+ }): Promise<{
290
+ access: string;
291
+ refresh: string;
292
+ expires: number;
293
+ email?: string;
294
+ displayName?: string;
295
+ }> {
296
+ const { verifier, challenge } = generatePkce();
297
+ const state = randomBytes(16).toString("hex");
298
+ const authUrl = buildAuthUrl({ challenge, state });
299
+
300
+ let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
301
+ const needsManual = shouldUseManualOAuthFlow(params.isRemote);
302
+ if (!needsManual) {
303
+ try {
304
+ callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
305
+ } catch {
306
+ callbackServer = null;
307
+ }
308
+ }
309
+
310
+ if (!callbackServer) {
311
+ await params.note(
312
+ [
313
+ "Open the URL in your local browser.",
314
+ "After signing in, copy the full redirect URL and paste it back here.",
315
+ "",
316
+ `Auth URL: ${authUrl}`,
317
+ `Redirect URI: ${REDIRECT_URI}`,
318
+ ].join("\n"),
319
+ "Outlook OAuth",
320
+ );
321
+ params.log("");
322
+ params.log("Copy this URL:");
323
+ params.log(authUrl);
324
+ params.log("");
325
+ }
326
+
327
+ if (!needsManual) {
328
+ params.progress.update("Opening Microsoft sign-in...");
329
+ try {
330
+ await params.openUrl(authUrl);
331
+ } catch {
332
+ // ignore
333
+ }
334
+ }
335
+
336
+ let code = "";
337
+ let returnedState = "";
338
+
339
+ if (callbackServer) {
340
+ params.progress.update("Waiting for OAuth callback...");
341
+ const callback = await callbackServer.waitForCallback();
342
+ code = callback.searchParams.get("code") ?? "";
343
+ returnedState = callback.searchParams.get("state") ?? "";
344
+ await callbackServer.close();
345
+ } else {
346
+ params.progress.update("Waiting for redirect URL...");
347
+ const input = await params.prompt("Paste the redirect URL: ");
348
+ const parsed = parseCallbackInput(input);
349
+ if ("error" in parsed) {
350
+ throw new Error(parsed.error);
351
+ }
352
+ code = parsed.code;
353
+ returnedState = parsed.state;
354
+ }
355
+
356
+ if (!code) {
357
+ throw new Error("Missing OAuth code");
358
+ }
359
+ if (returnedState !== state) {
360
+ throw new Error("OAuth state mismatch. Please try again.");
361
+ }
362
+
363
+ params.progress.update("Exchanging code for tokens...");
364
+ const tokens = await exchangeCode({ code, verifier });
365
+ const profile = await fetchUserProfile(tokens.access);
366
+
367
+ params.progress.stop("Outlook OAuth complete");
368
+ return { ...tokens, ...profile };
369
+ }
@@ -0,0 +1,150 @@
1
+ import { getAccessToken } from "./store.js";
2
+
3
+ const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
4
+
5
+ type GraphResponse<T> = { value?: T[]; "@odata.nextLink"?: string };
6
+
7
+ export type MailMessage = {
8
+ id: string;
9
+ subject: string;
10
+ from?: { emailAddress?: { name?: string; address?: string } };
11
+ toRecipients?: Array<{ emailAddress?: { name?: string; address?: string } }>;
12
+ receivedDateTime?: string;
13
+ bodyPreview?: string;
14
+ body?: { contentType?: string; content?: string };
15
+ isRead?: boolean;
16
+ hasAttachments?: boolean;
17
+ importance?: string;
18
+ };
19
+
20
+ export type MailFolder = {
21
+ id: string;
22
+ displayName: string;
23
+ totalItemCount?: number;
24
+ unreadItemCount?: number;
25
+ };
26
+
27
+ async function graphFetch<T>(path: string, init?: RequestInit): Promise<T> {
28
+ const token = await getAccessToken();
29
+ const response = await fetch(`${GRAPH_ROOT}${path}`, {
30
+ ...init,
31
+ headers: {
32
+ Authorization: `Bearer ${token}`,
33
+ "Content-Type": "application/json",
34
+ ...init?.headers,
35
+ },
36
+ });
37
+ if (!response.ok) {
38
+ const text = await response.text().catch(() => "");
39
+ throw new Error(`Graph API ${path} failed (${response.status}): ${text || "unknown error"}`);
40
+ }
41
+ if (response.status === 204) {
42
+ return {} as T;
43
+ }
44
+ return (await response.json()) as T;
45
+ }
46
+
47
+ export async function listMessages(params?: {
48
+ folder?: string;
49
+ top?: number;
50
+ skip?: number;
51
+ filter?: string;
52
+ search?: string;
53
+ }): Promise<MailMessage[]> {
54
+ const folder = params?.folder ?? "inbox";
55
+ const top = params?.top ?? 10;
56
+ const select =
57
+ "id,subject,from,toRecipients,receivedDateTime,bodyPreview,isRead,hasAttachments,importance";
58
+
59
+ const queryParts = [`$top=${top}`, `$select=${select}`, "$orderby=receivedDateTime desc"];
60
+
61
+ if (params?.skip) {
62
+ queryParts.push(`$skip=${params.skip}`);
63
+ }
64
+ if (params?.filter) {
65
+ queryParts.push(`$filter=${encodeURIComponent(params.filter)}`);
66
+ }
67
+ if (params?.search) {
68
+ queryParts.push(`$search="${encodeURIComponent(params.search)}"`);
69
+ }
70
+
71
+ const query = queryParts.join("&");
72
+ const result = await graphFetch<GraphResponse<MailMessage>>(
73
+ `/me/mailFolders/${encodeURIComponent(folder)}/messages?${query}`,
74
+ );
75
+ return result.value ?? [];
76
+ }
77
+
78
+ export async function readMessage(messageId: string): Promise<MailMessage> {
79
+ return await graphFetch<MailMessage>(
80
+ `/me/messages/${encodeURIComponent(messageId)}?$select=id,subject,from,toRecipients,receivedDateTime,body,isRead,hasAttachments,importance`,
81
+ );
82
+ }
83
+
84
+ export async function sendMessage(params: {
85
+ to: string[];
86
+ cc?: string[];
87
+ subject: string;
88
+ body: string;
89
+ contentType?: "Text" | "HTML";
90
+ }): Promise<void> {
91
+ const message = {
92
+ subject: params.subject,
93
+ body: {
94
+ contentType: params.contentType ?? "Text",
95
+ content: params.body,
96
+ },
97
+ toRecipients: params.to.map((addr) => ({
98
+ emailAddress: { address: addr },
99
+ })),
100
+ ...(params.cc?.length
101
+ ? {
102
+ ccRecipients: params.cc.map((addr) => ({
103
+ emailAddress: { address: addr },
104
+ })),
105
+ }
106
+ : {}),
107
+ };
108
+
109
+ await graphFetch<void>("/me/sendMail", {
110
+ method: "POST",
111
+ body: JSON.stringify({ message, saveToSentItems: true }),
112
+ });
113
+ }
114
+
115
+ export async function replyToMessage(params: {
116
+ messageId: string;
117
+ body: string;
118
+ replyAll?: boolean;
119
+ }): Promise<void> {
120
+ const action = params.replyAll ? "replyAll" : "reply";
121
+ await graphFetch<void>(`/me/messages/${encodeURIComponent(params.messageId)}/${action}`, {
122
+ method: "POST",
123
+ body: JSON.stringify({ comment: params.body }),
124
+ });
125
+ }
126
+
127
+ export async function searchMessages(query: string, top?: number): Promise<MailMessage[]> {
128
+ return listMessages({ search: query, top: top ?? 10 });
129
+ }
130
+
131
+ export async function listFolders(): Promise<MailFolder[]> {
132
+ const result = await graphFetch<GraphResponse<MailFolder>>(
133
+ "/me/mailFolders?$select=id,displayName,totalItemCount,unreadItemCount&$top=50",
134
+ );
135
+ return result.value ?? [];
136
+ }
137
+
138
+ export async function markAsRead(messageId: string): Promise<void> {
139
+ await graphFetch<void>(`/me/messages/${encodeURIComponent(messageId)}`, {
140
+ method: "PATCH",
141
+ body: JSON.stringify({ isRead: true }),
142
+ });
143
+ }
144
+
145
+ export async function moveMessage(messageId: string, destinationFolder: string): Promise<void> {
146
+ await graphFetch<void>(`/me/messages/${encodeURIComponent(messageId)}/move`, {
147
+ method: "POST",
148
+ body: JSON.stringify({ destinationId: destinationFolder }),
149
+ });
150
+ }
@@ -0,0 +1,72 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { refreshAccessToken } from "./auth.js";
4
+
5
+ export type OutlookCredentials = {
6
+ access: string;
7
+ refresh: string;
8
+ expires: number;
9
+ email?: string;
10
+ displayName?: string;
11
+ updatedAt?: string;
12
+ };
13
+
14
+ const STORE_DIR = path.join(
15
+ process.env.HOME || process.env.USERPROFILE || "/tmp",
16
+ ".symi",
17
+ "credentials",
18
+ );
19
+
20
+ const STORE_PATH = path.join(STORE_DIR, "outlook.json");
21
+
22
+ export function loadCredentials(): OutlookCredentials | null {
23
+ try {
24
+ const data = fs.readFileSync(STORE_PATH, "utf-8");
25
+ return JSON.parse(data) as OutlookCredentials;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export function saveCredentials(creds: OutlookCredentials): void {
32
+ fs.mkdirSync(STORE_DIR, { recursive: true });
33
+ creds.updatedAt = new Date().toISOString();
34
+ fs.writeFileSync(STORE_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
35
+ }
36
+
37
+ export function deleteCredentials(): boolean {
38
+ try {
39
+ fs.unlinkSync(STORE_PATH);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Returns a valid access token, refreshing if expired.
48
+ * Throws if no credentials stored or refresh fails.
49
+ */
50
+ export async function getAccessToken(): Promise<string> {
51
+ const creds = loadCredentials();
52
+ if (!creds) {
53
+ throw new Error(
54
+ "Outlook not connected. Run `symi outlook login` to sign in with your Microsoft account.",
55
+ );
56
+ }
57
+
58
+ // Token still valid
59
+ if (Date.now() < creds.expires) {
60
+ return creds.access;
61
+ }
62
+
63
+ // Refresh the token
64
+ const refreshed = await refreshAccessToken(creds.refresh);
65
+ saveCredentials({
66
+ ...creds,
67
+ access: refreshed.access,
68
+ refresh: refreshed.refresh,
69
+ expires: refreshed.expires,
70
+ });
71
+ return refreshed.access;
72
+ }