diff --git a/desktop-sidecar-protocol/typescript/src/index.ts b/desktop-sidecar-protocol/typescript/src/index.ts index 3f4909069..7e1e9d5c4 100644 --- a/desktop-sidecar-protocol/typescript/src/index.ts +++ b/desktop-sidecar-protocol/typescript/src/index.ts @@ -34,13 +34,20 @@ export type { DiscoverResult, DiscoveryDocument, JsonRpcEnvelope, + OutlookAttachmentReference, + OutlookConversationMessage, + OutlookConversationWarning, OutlookEmailSummary, + OutlookGetConversationV1Params, + OutlookGetConversationV1Result, OutlookListingWarning, OutlookListEmailsV1Params, OutlookListEmailsV1Result, OutlookListMailboxesV1Params, OutlookListMailboxesV1Result, OutlookMailbox, + OutlookMessageBody, + OutlookMessageRecipient, ProtocolErrorData, SidecarRestartV1Params, SidecarRestartV1Result, diff --git a/frontend/src/library/index.ts b/frontend/src/library/index.ts index 37098389d..292f6812e 100644 --- a/frontend/src/library/index.ts +++ b/frontend/src/library/index.ts @@ -124,6 +124,14 @@ export { type DesktopSidecarContextValue, type DesktopSidecarProviderProps, } from "@/providers/DesktopSidecarProvider"; +// Protocol types the Outlook add-in needs to map a sidecar conversation. +export type { + DesktopSidecarClient, + OutlookAttachmentReference, + OutlookConversationMessage, + OutlookMessageBody, + OutlookMessageRecipient, +} from "@erato/desktop-sidecar-protocol"; export { FileCapabilitiesProvider, useFileCapabilitiesContext, diff --git a/office-addin/src/app/env.ts b/office-addin/src/app/env.ts index d4c54d088..6bb3a1e1f 100644 --- a/office-addin/src/app/env.ts +++ b/office-addin/src/app/env.ts @@ -4,6 +4,10 @@ export function injectFrontendEnv() { window.FRONTEND_PUBLIC_BASE_PATH ??= "/public/platform-office-addin"; window.COMMON_PUBLIC_BASE_PATH ??= "/public/common"; + if (import.meta.env.VITE_DESKTOP_SIDECAR_URL) { + window.DESKTOP_SIDECAR_URL ??= import.meta.env.VITE_DESKTOP_SIDECAR_URL; + } + if (import.meta.env.VITE_CUSTOMER_NAME) { window.THEME_CUSTOMER_NAME ??= import.meta.env.VITE_CUSTOMER_NAME; } diff --git a/office-addin/src/components/__tests__/AddinChat.test.tsx b/office-addin/src/components/__tests__/AddinChat.test.tsx index 609d90852..d3abad54c 100644 --- a/office-addin/src/components/__tests__/AddinChat.test.tsx +++ b/office-addin/src/components/__tests__/AddinChat.test.tsx @@ -87,6 +87,8 @@ vi.mock("@erato/frontend/library", () => ({ currentChatLastModel: undefined, }), useConversationDropzone: useConversationDropzoneMock, + // useOutlookMessageFetcher reads the sidecar client; none is mounted here. + useDesktopSidecar: () => ({ client: null }), useFileCapabilitiesContext: () => ({ capabilities: {} }), useFilePreviewModal: () => ({ isPreviewModalOpen: false, diff --git a/office-addin/src/hooks/useOutlookMessageFetcher.ts b/office-addin/src/hooks/useOutlookMessageFetcher.ts index c796861ae..dafcee3b3 100644 --- a/office-addin/src/hooks/useOutlookMessageFetcher.ts +++ b/office-addin/src/hooks/useOutlookMessageFetcher.ts @@ -1,12 +1,15 @@ +import { useDesktopSidecar } from "@erato/frontend/library"; import { useMemo } from "react"; import { useGraphTokenOptional } from "../providers/EntraGraphTokenProvider"; +import { useOutlookMailItem } from "../providers/OutlookMailItemProvider"; import { useSessionAuth } from "../providers/SessionAuthProvider"; import { detectExchangeOnPrem } from "../utils/detectExchangeOnPrem"; import { createEwsOutlookMessageFetcher, createGraphOutlookMessageFetcher, } from "../utils/fetchOutlookMessage"; +import { createSidecarOutlookMessageFetcher } from "../utils/fetchOutlookMessageSidecar"; import type { OutlookMessageFetcher } from "../utils/fetchOutlookMessage"; import type { AcquireGraphToken } from "../utils/fetchOutlookMessageGraph"; @@ -55,16 +58,27 @@ export function useOutlookMessageFetcher(): UseOutlookMessageFetcherResult { // The mailbox host can't change within a session, so the probe is stable — // compute it once and feed the stable value into the dispatch memo. const isOnPrem = useMemo(() => detectExchangeOnPrem(), []); + // The desktop sidecar augments only the on-prem/SE conversation path. + const { client: sidecarClient } = useDesktopSidecar(); + const { mailItem } = useOutlookMailItem(); + const anchorInternetMessageId = mailItem?.internetMessageId ?? null; return useMemo(() => { if (mode !== "entra-msal") { return { fetcher: null, unavailableReason: "unsupported-mode" }; } if (isOnPrem) { - return { - fetcher: createEwsOutlookMessageFetcher(), - unavailableReason: null, - }; + const ews = createEwsOutlookMessageFetcher(); + const fetcher = sidecarClient + ? createSidecarOutlookMessageFetcher({ + inner: ews, + client: sidecarClient, + anchorInternetMessageId, + userEmailAddress: + Office.context?.mailbox?.userProfile?.emailAddress ?? null, + }) + : ews; + return { fetcher, unavailableReason: null }; } if (!graph) { return { fetcher: null, unavailableReason: "graph-unavailable" }; @@ -75,5 +89,5 @@ export function useOutlookMessageFetcher(): UseOutlookMessageFetcherResult { fetcher: createGraphOutlookMessageFetcher(acquireGraphToken), unavailableReason: null, }; - }, [graph, mode, isOnPrem]); + }, [graph, mode, isOnPrem, sidecarClient, anchorInternetMessageId]); } diff --git a/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts b/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts new file mode 100644 index 000000000..c662d304d --- /dev/null +++ b/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createSidecarOutlookMessageFetcher } from "../fetchOutlookMessageSidecar"; + +import type { OutlookMessageFetcher } from "../fetchOutlookMessage"; +import type { FetchConversationResult } from "../fetchOutlookMessageGraph"; +import type { DesktopSidecarClient } from "@erato/frontend/library"; + +const FALLBACK: FetchConversationResult = { messages: [], state: "error" }; +// Base64 as the sidecar carries it inline on the wire. +const ATTACHMENT_BYTES = btoa("att01"); + +function stubInner(): OutlookMessageFetcher { + return { + fetchMessageBytes: vi.fn(), + fetchMessageFilesByInternetMessageId: vi.fn(), + fetchMessageBytesByInternetMessageId: vi.fn(), + fetchConversationMessages: vi.fn(async () => FALLBACK), + fetchParentMessageInConversation: vi.fn(), + }; +} + +interface FakeClientOptions { + supports?: boolean; + mailboxes?: { id: string; emailAddress?: string }[]; + conversation?: unknown; +} + +function defaultConversation() { + return { + state: "ok", + messages: [ + { + internetMessageId: "", + subject: "Hello", + from: { name: "A", emailAddress: "a@example.test" }, + to: [{ name: "B", emailAddress: "b@example.test" }], + cc: [], + sentAtUnixSeconds: 1_700_000_000, + isDraft: false, + body: { contentType: "text/html", content: "

Hi

" }, + attachments: [ + { + name: "doc.pdf", + contentType: "application/pdf", + size: 5, + isInline: false, + sha256: "a".repeat(64), + contentBytes: ATTACHMENT_BYTES, + }, + ], + }, + ], + }; +} + +function fakeClient(options: FakeClientOptions = {}): DesktopSidecarClient { + const { + supports = true, + mailboxes = [{ id: "m1", emailAddress: "user@example.test" }], + conversation = defaultConversation(), + } = options; + return { + supports: () => supports, + invoke: async (method: string) => { + if (method === "outlook.list_mailboxes.v1") return { mailboxes }; + if (method === "outlook.get_conversation.v1") return conversation; + throw new Error(`unexpected invoke ${method}`); + }, + } as unknown as DesktopSidecarClient; +} + +function context(client: DesktopSidecarClient, inner: OutlookMessageFetcher) { + return { + inner, + client, + anchorInternetMessageId: "", + userEmailAddress: "user@example.test", + }; +} + +describe("createSidecarOutlookMessageFetcher", () => { + it("maps a conversation with inline body and attachment bytes", async () => { + const inner = stubInner(); + const fetcher = createSidecarOutlookMessageFetcher( + context(fakeClient(), inner), + ); + + const result = await fetcher.fetchConversationMessages("conv-1"); + + expect(inner.fetchConversationMessages).not.toHaveBeenCalled(); + expect(result.state).toBe("ok"); + const message = result.messages[0]; + expect(message.internetMessageId).toBe(""); + expect(message.body).toEqual({ contentType: "html", content: "

Hi

" }); + + const attachment = message.attachments![0]; + // The bug review caught: attachments must carry an id, or transformAttachment + // drops them downstream — defeating the whole sidecar-attachments feature. + expect(attachment.id).toBeDefined(); + expect(attachment.contentBytes).toBe(ATTACHMENT_BYTES); + expect(atob(attachment.contentBytes!)).toBe("att01"); + }); + + it("falls back to the inner fetcher when the capability is unsupported", async () => { + const inner = stubInner(); + const fetcher = createSidecarOutlookMessageFetcher( + context(fakeClient({ supports: false }), inner), + ); + + const result = await fetcher.fetchConversationMessages("conv-1"); + + expect(result).toBe(FALLBACK); + expect(inner.fetchConversationMessages).toHaveBeenCalledOnce(); + }); + + it("falls back when no local mailbox matches the user", async () => { + const inner = stubInner(); + const fetcher = createSidecarOutlookMessageFetcher( + context( + fakeClient({ + mailboxes: [{ id: "m1", emailAddress: "other@example.test" }], + }), + inner, + ), + ); + + expect(await fetcher.fetchConversationMessages("conv-1")).toBe(FALLBACK); + }); + + it("falls back when the anchor resolves to no messages", async () => { + const inner = stubInner(); + const fetcher = createSidecarOutlookMessageFetcher( + context( + fakeClient({ conversation: { state: "ok", messages: [] } }), + inner, + ), + ); + + expect(await fetcher.fetchConversationMessages("conv-1")).toBe(FALLBACK); + expect(inner.fetchConversationMessages).toHaveBeenCalledOnce(); + }); + + it("degrades an attachment with no bytes to a marker + partial, not a full fallback", async () => { + const inner = stubInner(); + // The sidecar could not read this attachment: no contentBytes, only a reason. + const conversation = { + state: "ok", + messages: [ + { + internetMessageId: "", + subject: "Hello", + from: { name: "A", emailAddress: "a@example.test" }, + to: [], + cc: [], + sentAtUnixSeconds: 1_700_000_000, + isDraft: false, + body: { contentType: "text/html", content: "

Hi

" }, + attachments: [ + { + name: "doc.pdf", + contentType: "application/pdf", + size: 5, + isInline: false, + sha256: "a".repeat(64), + unavailableReason: "unsupported_attachment", + }, + ], + }, + ], + }; + const fetcher = createSidecarOutlookMessageFetcher( + context(fakeClient({ conversation }), inner), + ); + + const result = await fetcher.fetchConversationMessages("conv-1"); + + expect(inner.fetchConversationMessages).not.toHaveBeenCalled(); + expect(result.state).toBe("partial"); + const attachment = result.messages[0].attachments![0]; + expect(attachment.id).toBeDefined(); + expect(attachment.contentBytes).toBeUndefined(); + }); +}); diff --git a/office-addin/src/utils/fetchOutlookMessageSidecar.ts b/office-addin/src/utils/fetchOutlookMessageSidecar.ts new file mode 100644 index 000000000..0a93556f1 --- /dev/null +++ b/office-addin/src/utils/fetchOutlookMessageSidecar.ts @@ -0,0 +1,219 @@ +import type { OutlookMessageFetcher } from "./fetchOutlookMessage"; +import type { + FetchConversationOptions, + FetchConversationResult, + GraphAttachment, + GraphBody, + GraphConversationMessage, + GraphRecipient, +} from "./fetchOutlookMessageGraph"; +import type { + DesktopSidecarClient, + OutlookMessageBody, + OutlookConversationMessage, + OutlookAttachmentReference, + OutlookMessageRecipient, +} from "@erato/frontend/library"; + +const GET_CONVERSATION = "outlook.get_conversation.v1"; +/** Discriminator that makes downstream treat a byte-less attachment as a plain + * file with no retrievable content (an accurate disclosure marker). */ +const FILE_ATTACHMENT_TYPE = "#microsoft.graph.fileAttachment"; + +export interface SidecarFetcherContext { + /** The fetcher to delegate to for everything but conversation loading, and + * to fall back to when the conversation itself can't be fetched. */ + inner: OutlookMessageFetcher; + client: DesktopSidecarClient; + /** RFC 5322 Message-ID of the current item — the conversation anchor. */ + anchorInternetMessageId: string | null; + /** The signed-in user's SMTP address, used to select the local mailbox. */ + userEmailAddress: string | null; +} + +/** Tracks whether any part of a conversation had to be degraded (an attachment + * the sidecar could not read), so the result is reported as `partial`. */ +interface ConversionProgress { + partial: boolean; +} + +/** + * Wrap an {@link OutlookMessageFetcher} so conversation loads go through the + * desktop sidecar — whole thread plus any-size attachments read from the local + * Outlook store — when it is available, bypassing the `makeEwsRequestAsync` + * size caps that otherwise degrade Exchange SE threads to byte-less markers. + * + * Only `fetchConversationMessages` is overridden; every other capability + * delegates unchanged. The conversation itself failing (unsupported, no mailbox + * match, RPC error) falls back to the wrapped fetcher, so the result is never + * worse than the EWS path. The sidecar carries message bodies and attachment + * bytes inline in the result; an attachment it could not read arrives with no + * bytes, degrading just that item to a marker and marking the result `partial` + * rather than discarding the whole thread. + */ +export function createSidecarOutlookMessageFetcher( + context: SidecarFetcherContext, +): OutlookMessageFetcher { + const { inner, client } = context; + return { + ...inner, + fetchConversationMessages: async (conversationId, options) => { + if ( + !client.supports(GET_CONVERSATION) || + !context.anchorInternetMessageId || + !context.userEmailAddress + ) { + return inner.fetchConversationMessages(conversationId, options); + } + try { + return await fetchConversationViaSidecar(context, options); + } catch (error) { + if (options?.signal?.aborted) { + throw error; + } + return inner.fetchConversationMessages(conversationId, options); + } + }, + }; +} + +async function fetchConversationViaSidecar( + context: SidecarFetcherContext, + options: FetchConversationOptions | undefined, +): Promise { + const { client } = context; + const anchorInternetMessageId = context.anchorInternetMessageId!; + const userEmailAddress = context.userEmailAddress!; + const signal = options?.signal; + + const mailboxId = await resolveMailboxId(client, userEmailAddress, signal); + if (!mailboxId) { + throw new Error("No local Outlook mailbox matches the signed-in user."); + } + + const conversation = await client.invoke( + GET_CONVERSATION, + { mailboxId, anchor: { internetMessageId: anchorInternetMessageId } }, + { signal }, + ); + + if (conversation.messages.length === 0) { + // A conversation always contains at least its anchor, so an empty result + // means the anchor Message-ID was not resolvable in the local store (a + // format mismatch, or an item not yet synced to the OST). Fall back to EWS + // rather than presenting an empty thread. + throw new Error("The sidecar returned no messages for the anchor."); + } + + const progress: ConversionProgress = { partial: conversation.state !== "ok" }; + const messages = conversation.messages.map((message) => + mapConversationMessage(message, progress), + ); + + return { messages, state: progress.partial ? "partial" : "ok" }; +} + +async function resolveMailboxId( + client: DesktopSidecarClient, + userEmailAddress: string, + signal: AbortSignal | undefined, +): Promise { + const { mailboxes } = await client.invoke( + "outlook.list_mailboxes.v1", + {}, + { signal }, + ); + const target = userEmailAddress.trim().toLowerCase(); + const match = mailboxes.find( + (mailbox) => mailbox.emailAddress?.trim().toLowerCase() === target, + ); + return match?.id ?? null; +} + +function mapConversationMessage( + message: OutlookConversationMessage, + progress: ConversionProgress, +): GraphConversationMessage { + return { + internetMessageId: message.internetMessageId, + subject: message.subject, + from: toGraphRecipient(message.from), + toRecipients: toGraphRecipients(message.to), + ccRecipients: toGraphRecipients(message.cc), + sentDateTime: toIsoDate(message.sentAtUnixSeconds), + receivedDateTime: toIsoDate(message.receivedAtUnixSeconds), + isDraft: message.isDraft, + body: mapBody(message.body), + attachments: message.attachments.map((attachment, index) => + mapAttachment(attachment, index, progress), + ), + }; +} + +function mapBody(body: OutlookMessageBody | undefined): GraphBody | undefined { + if (!body) { + return undefined; + } + return { + contentType: body.contentType.includes("html") ? "html" : "text", + content: body.content, + }; +} + +function mapAttachment( + attachment: OutlookAttachmentReference, + index: number, + progress: ConversionProgress, +): GraphAttachment { + // A stable, unique id is required downstream — `transformAttachment` drops any + // attachment without one. The sha256 is stable across re-fetch (so per-item + // dismissal persists); the index disambiguates byte-identical siblings and + // covers unavailable attachments that carry no hash. + const base: GraphAttachment = { + "@odata.type": FILE_ATTACHMENT_TYPE, + id: attachment.sha256 + ? `${attachment.sha256}-${index}` + : `sidecar-attachment-${index}`, + name: attachment.name, + contentType: attachment.contentType, + size: attachment.size, + isInline: attachment.isInline, + contentId: attachment.contentId, + }; + if (attachment.contentBytes === undefined) { + progress.partial = true; + return base; + } + // The wire already carries base64, so it maps straight onto Graph's shape. + return { ...base, contentBytes: attachment.contentBytes }; +} + +function toGraphRecipients( + recipients: readonly OutlookMessageRecipient[] | undefined, +): GraphRecipient[] | undefined { + if (!recipients) { + return undefined; + } + return recipients + .map(toGraphRecipient) + .filter( + (recipient): recipient is GraphRecipient => recipient !== undefined, + ); +} + +function toGraphRecipient( + recipient: OutlookMessageRecipient | undefined, +): GraphRecipient | undefined { + if (!recipient || (!recipient.name && !recipient.emailAddress)) { + return undefined; + } + return { + emailAddress: { name: recipient.name, address: recipient.emailAddress }, + }; +} + +function toIsoDate(unixSeconds: number | undefined): string | undefined { + return unixSeconds === undefined + ? undefined + : new Date(unixSeconds * 1000).toISOString(); +}