From 6dc12488e62fde2f33dd9b90e7f3bb4795793b2e Mon Sep 17 00:00:00 2001 From: bdart Date: Thu, 30 Jul 2026 11:15:44 +0200 Subject: [PATCH 1/5] Add sidecar-backed conversation fetcher for the add-in --- .../typescript/src/index.ts | 7 + frontend/src/library/index.ts | 8 + .../src/hooks/useOutlookMessageFetcher.ts | 24 +- .../src/utils/fetchOutlookMessageSidecar.ts | 229 ++++++++++++++++++ 4 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 office-addin/src/utils/fetchOutlookMessageSidecar.ts diff --git a/desktop-sidecar-protocol/typescript/src/index.ts b/desktop-sidecar-protocol/typescript/src/index.ts index 3f4909069..8db7eed66 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, + OutlookBodyHandle, + OutlookConversationMessage, + OutlookConversationWarning, OutlookEmailSummary, + OutlookGetConversationV1Params, + OutlookGetConversationV1Result, OutlookListingWarning, OutlookListEmailsV1Params, OutlookListEmailsV1Result, OutlookListMailboxesV1Params, OutlookListMailboxesV1Result, OutlookMailbox, + OutlookMessageRecipient, ProtocolErrorData, SidecarRestartV1Params, SidecarRestartV1Result, diff --git a/frontend/src/library/index.ts b/frontend/src/library/index.ts index 37098389d..7d15956ae 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, + OutlookBodyHandle, + OutlookConversationMessage, + OutlookMessageRecipient, +} from "@erato/desktop-sidecar-protocol"; export { FileCapabilitiesProvider, useFileCapabilitiesContext, 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/fetchOutlookMessageSidecar.ts b/office-addin/src/utils/fetchOutlookMessageSidecar.ts new file mode 100644 index 000000000..b7e761cd5 --- /dev/null +++ b/office-addin/src/utils/fetchOutlookMessageSidecar.ts @@ -0,0 +1,229 @@ +import type { + DesktopSidecarClient, + OutlookBodyHandle, + OutlookConversationMessage, + OutlookAttachmentReference, + OutlookMessageRecipient, +} from "@erato/frontend/library"; + +import type { OutlookMessageFetcher } from "./fetchOutlookMessage"; +import type { + FetchConversationOptions, + FetchConversationResult, + GraphAttachment, + GraphBody, + GraphConversationMessage, + GraphRecipient, +} from "./fetchOutlookMessageGraph"; + +const GET_CONVERSATION = "outlook.get_conversation.v1"; +/** Max simultaneous transfer fetches, mirroring the Graph item-enrich guard. */ +const TRANSFER_CONCURRENCY = 5; + +export interface SidecarFetcherContext { + /** The fetcher to delegate to for everything but conversation loading, and + * to fall back to on any sidecar failure. */ + 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; +} + +/** + * 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. **Any** sidecar failure (unsupported, no mailbox match, + * RPC/transfer error) falls back to the wrapped fetcher, so the result is never + * worse than the EWS path alone. + */ +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 { + 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 }, + ); + + const messages = await mapWithConcurrency( + conversation.messages, + TRANSFER_CONCURRENCY, + (message) => mapConversationMessage(client, message, signal), + ); + + return { messages, state: conversation.state === "ok" ? "ok" : "partial" }; +} + +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; +} + +async function mapConversationMessage( + client: DesktopSidecarClient, + message: OutlookConversationMessage, + signal: AbortSignal | undefined, +): Promise { + const [body, attachments] = await Promise.all([ + mapBody(client, message.bodyHandle, signal), + mapWithConcurrency(message.attachments, TRANSFER_CONCURRENCY, (attachment) => + mapAttachment(client, attachment, signal), + ), + ]); + + 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, + attachments, + }; +} + +async function mapBody( + client: DesktopSidecarClient, + bodyHandle: OutlookBodyHandle | undefined, + signal: AbortSignal | undefined, +): Promise { + if (!bodyHandle) { + return undefined; + } + const bytes = await client.fetchTransfer(bodyHandle.handle, { signal }); + return { + contentType: bodyHandle.contentType.includes("html") ? "html" : "text", + content: new TextDecoder().decode(bytes), + }; +} + +async function mapAttachment( + client: DesktopSidecarClient, + attachment: OutlookAttachmentReference, + signal: AbortSignal | undefined, +): Promise { + const base: GraphAttachment = { + name: attachment.name, + contentType: attachment.contentType, + size: attachment.size, + isInline: attachment.isInline, + contentId: attachment.contentId, + }; + if (!attachment.contentHandle) { + return base; + } + const bytes = await client.fetchTransfer(attachment.contentHandle, { signal }); + return { ...base, contentBytes: bytesToBase64(bytes) }; +} + +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(); +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); +} + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + map: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let next = 0; + const workers = Array.from( + { length: Math.min(limit, items.length) }, + async () => { + while (next < items.length) { + const current = next++; + results[current] = await map(items[current], current); + } + }, + ); + await Promise.all(workers); + return results; +} From f5d668a14d6ca80c32b2af4b4f04589c0523258f Mon Sep 17 00:00:00 2001 From: bdart Date: Thu, 30 Jul 2026 12:53:12 +0200 Subject: [PATCH 2/5] Fix sidecar attachment mapping: ids, byte-less markers, per-item resilience --- .../fetchOutlookMessageSidecar.test.ts | 153 ++++++++++++++++++ .../src/utils/fetchOutlookMessageSidecar.ts | 81 ++++++++-- 2 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts 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..6e1ed7867 --- /dev/null +++ b/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createSidecarOutlookMessageFetcher } from "../fetchOutlookMessageSidecar"; + +import type { DesktopSidecarClient } from "@erato/frontend/library"; +import type { OutlookMessageFetcher } from "../fetchOutlookMessage"; +import type { FetchConversationResult } from "../fetchOutlookMessageGraph"; + +const FALLBACK: FetchConversationResult = { messages: [], state: "error" }; + +function stubInner(): OutlookMessageFetcher { + return { + fetchMessageBytes: vi.fn(), + fetchMessageFilesByInternetMessageId: vi.fn(), + fetchMessageBytesByInternetMessageId: vi.fn(), + fetchConversationMessages: vi.fn(async () => FALLBACK), + fetchParentMessageInConversation: vi.fn(), + } as unknown as OutlookMessageFetcher; +} + +interface FakeClientOptions { + supports?: boolean; + mailboxes?: { id: string; emailAddress?: string }[]; + transfer?: (handle: string) => Promise; +} + +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, + bodyHandle: { handle: "body01", contentType: "text/html", size: 6 }, + attachments: [ + { + name: "doc.pdf", + contentType: "application/pdf", + size: 5, + isInline: false, + sha256: "a".repeat(64), + contentHandle: "att01", + }, + ], + }, + ], + }; +} + +function fakeClient(options: FakeClientOptions = {}): DesktopSidecarClient { + const { + supports = true, + mailboxes = [{ id: "m1", emailAddress: "user@example.test" }], + transfer = async (handle: string) => new TextEncoder().encode(handle), + } = options; + return { + supports: () => supports, + invoke: async (method: string) => { + if (method === "outlook.list_mailboxes.v1") return { mailboxes }; + if (method === "outlook.get_conversation.v1") + return defaultConversation(); + throw new Error(`unexpected invoke ${method}`); + }, + fetchTransfer: (handle: string) => transfer(handle), + } 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 fetchable 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: "body01" }); + + 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).toBeDefined(); + 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("degrades a failed attachment transfer to a marker + partial, not a full fallback", async () => { + const inner = stubInner(); + const fetcher = createSidecarOutlookMessageFetcher( + context( + fakeClient({ + transfer: async (handle) => { + if (handle === "att01") throw new Error("transfer failed"); + return new TextEncoder().encode(handle); + }, + }), + 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 index b7e761cd5..468e83e06 100644 --- a/office-addin/src/utils/fetchOutlookMessageSidecar.ts +++ b/office-addin/src/utils/fetchOutlookMessageSidecar.ts @@ -17,12 +17,17 @@ import type { } from "./fetchOutlookMessageGraph"; const GET_CONVERSATION = "outlook.get_conversation.v1"; -/** Max simultaneous transfer fetches, mirroring the Graph item-enrich guard. */ +/** Max simultaneous transfer fetches per level — messages, and attachments + * within a message — so the effective ceiling is this squared plus one body + * fetch per message. Loopback transfers, so the bound is generous by design. */ const TRANSFER_CONCURRENCY = 5; +/** 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 on any sidecar failure. */ + * 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. */ @@ -31,6 +36,12 @@ export interface SidecarFetcherContext { userEmailAddress: string | null; } +/** Tracks whether any part of a conversation had to be degraded (a body or + * attachment whose bytes could not be fetched), so the result is `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 @@ -38,9 +49,11 @@ export interface SidecarFetcherContext { * size caps that otherwise degrade Exchange SE threads to byte-less markers. * * Only `fetchConversationMessages` is overridden; every other capability - * delegates unchanged. **Any** sidecar failure (unsupported, no mailbox match, - * RPC/transfer error) falls back to the wrapped fetcher, so the result is never - * worse than the EWS path alone. + * 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. Once the conversation is fetched, a single body or + * attachment transfer failure degrades just that item to a byte-less marker and + * marks the result `partial`, rather than discarding the whole thread. */ export function createSidecarOutlookMessageFetcher( context: SidecarFetcherContext, @@ -58,7 +71,10 @@ export function createSidecarOutlookMessageFetcher( } try { return await fetchConversationViaSidecar(context, options); - } catch { + } catch (error) { + if (options?.signal?.aborted) { + throw error; + } return inner.fetchConversationMessages(conversationId, options); } }, @@ -85,13 +101,14 @@ async function fetchConversationViaSidecar( { signal }, ); + const progress: ConversionProgress = { partial: conversation.state !== "ok" }; const messages = await mapWithConcurrency( conversation.messages, TRANSFER_CONCURRENCY, - (message) => mapConversationMessage(client, message, signal), + (message) => mapConversationMessage(client, message, signal, progress), ); - return { messages, state: conversation.state === "ok" ? "ok" : "partial" }; + return { messages, state: progress.partial ? "partial" : "ok" }; } async function resolveMailboxId( @@ -115,11 +132,15 @@ async function mapConversationMessage( client: DesktopSidecarClient, message: OutlookConversationMessage, signal: AbortSignal | undefined, + progress: ConversionProgress, ): Promise { const [body, attachments] = await Promise.all([ - mapBody(client, message.bodyHandle, signal), - mapWithConcurrency(message.attachments, TRANSFER_CONCURRENCY, (attachment) => - mapAttachment(client, attachment, signal), + mapBody(client, message.bodyHandle, signal, progress), + mapWithConcurrency( + message.attachments, + TRANSFER_CONCURRENCY, + (attachment, index) => + mapAttachment(client, attachment, index, signal, progress), ), ]); @@ -141,11 +162,21 @@ async function mapBody( client: DesktopSidecarClient, bodyHandle: OutlookBodyHandle | undefined, signal: AbortSignal | undefined, + progress: ConversionProgress, ): Promise { if (!bodyHandle) { return undefined; } - const bytes = await client.fetchTransfer(bodyHandle.handle, { signal }); + let bytes: Uint8Array; + try { + bytes = await client.fetchTransfer(bodyHandle.handle, { signal }); + } catch (error) { + if (signal?.aborted) { + throw error; + } + progress.partial = true; + return undefined; + } return { contentType: bodyHandle.contentType.includes("html") ? "html" : "text", content: new TextDecoder().decode(bytes), @@ -155,9 +186,19 @@ async function mapBody( async function mapAttachment( client: DesktopSidecarClient, attachment: OutlookAttachmentReference, + index: number, signal: AbortSignal | undefined, + progress: ConversionProgress, ): Promise { + // 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, @@ -165,9 +206,19 @@ async function mapAttachment( contentId: attachment.contentId, }; if (!attachment.contentHandle) { + progress.partial = true; + return base; + } + let bytes: Uint8Array; + try { + bytes = await client.fetchTransfer(attachment.contentHandle, { signal }); + } catch (error) { + if (signal?.aborted) { + throw error; + } + progress.partial = true; return base; } - const bytes = await client.fetchTransfer(attachment.contentHandle, { signal }); return { ...base, contentBytes: bytesToBase64(bytes) }; } @@ -179,7 +230,9 @@ function toGraphRecipients( } return recipients .map(toGraphRecipient) - .filter((recipient): recipient is GraphRecipient => recipient !== undefined); + .filter( + (recipient): recipient is GraphRecipient => recipient !== undefined, + ); } function toGraphRecipient( From dc815e8af165d0b016a69e84b177262b60566438 Mon Sep 17 00:00:00 2001 From: bdart Date: Thu, 30 Jul 2026 14:04:46 +0200 Subject: [PATCH 3/5] Fall back to EWS when the sidecar resolves no messages for the anchor --- .../fetchOutlookMessageSidecar.test.ts | 18 ++++++++++++++++-- .../src/utils/fetchOutlookMessageSidecar.ts | 8 ++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts b/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts index 6e1ed7867..5e12c3236 100644 --- a/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts +++ b/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts @@ -21,6 +21,7 @@ function stubInner(): OutlookMessageFetcher { interface FakeClientOptions { supports?: boolean; mailboxes?: { id: string; emailAddress?: string }[]; + conversation?: unknown; transfer?: (handle: string) => Promise; } @@ -56,14 +57,14 @@ function fakeClient(options: FakeClientOptions = {}): DesktopSidecarClient { const { supports = true, mailboxes = [{ id: "m1", emailAddress: "user@example.test" }], + conversation = defaultConversation(), transfer = async (handle: string) => new TextEncoder().encode(handle), } = options; return { supports: () => supports, invoke: async (method: string) => { if (method === "outlook.list_mailboxes.v1") return { mailboxes }; - if (method === "outlook.get_conversation.v1") - return defaultConversation(); + if (method === "outlook.get_conversation.v1") return conversation; throw new Error(`unexpected invoke ${method}`); }, fetchTransfer: (handle: string) => transfer(handle), @@ -128,6 +129,19 @@ describe("createSidecarOutlookMessageFetcher", () => { 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 a failed attachment transfer to a marker + partial, not a full fallback", async () => { const inner = stubInner(); const fetcher = createSidecarOutlookMessageFetcher( diff --git a/office-addin/src/utils/fetchOutlookMessageSidecar.ts b/office-addin/src/utils/fetchOutlookMessageSidecar.ts index 468e83e06..3e293d21f 100644 --- a/office-addin/src/utils/fetchOutlookMessageSidecar.ts +++ b/office-addin/src/utils/fetchOutlookMessageSidecar.ts @@ -101,6 +101,14 @@ async function fetchConversationViaSidecar( { 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 = await mapWithConcurrency( conversation.messages, From 0159f2fee88ccddd22bf441da64446c3330e63b6 Mon Sep 17 00:00:00 2001 From: bdart Date: Thu, 30 Jul 2026 16:30:08 +0200 Subject: [PATCH 4/5] Consume inline get_conversation bytes in the add-in fetcher --- .../typescript/src/index.ts | 2 +- frontend/src/library/index.ts | 2 +- .../components/__tests__/AddinChat.test.tsx | 2 + .../fetchOutlookMessageSidecar.test.ts | 57 +++++--- .../src/utils/fetchOutlookMessageSidecar.ts | 131 ++++-------------- 5 files changed, 71 insertions(+), 123 deletions(-) diff --git a/desktop-sidecar-protocol/typescript/src/index.ts b/desktop-sidecar-protocol/typescript/src/index.ts index 8db7eed66..7e1e9d5c4 100644 --- a/desktop-sidecar-protocol/typescript/src/index.ts +++ b/desktop-sidecar-protocol/typescript/src/index.ts @@ -35,7 +35,6 @@ export type { DiscoveryDocument, JsonRpcEnvelope, OutlookAttachmentReference, - OutlookBodyHandle, OutlookConversationMessage, OutlookConversationWarning, OutlookEmailSummary, @@ -47,6 +46,7 @@ export type { OutlookListMailboxesV1Params, OutlookListMailboxesV1Result, OutlookMailbox, + OutlookMessageBody, OutlookMessageRecipient, ProtocolErrorData, SidecarRestartV1Params, diff --git a/frontend/src/library/index.ts b/frontend/src/library/index.ts index 7d15956ae..292f6812e 100644 --- a/frontend/src/library/index.ts +++ b/frontend/src/library/index.ts @@ -128,8 +128,8 @@ export { export type { DesktopSidecarClient, OutlookAttachmentReference, - OutlookBodyHandle, OutlookConversationMessage, + OutlookMessageBody, OutlookMessageRecipient, } from "@erato/desktop-sidecar-protocol"; export { 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/utils/__tests__/fetchOutlookMessageSidecar.test.ts b/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts index 5e12c3236..c662d304d 100644 --- a/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts +++ b/office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it, vi } from "vitest"; import { createSidecarOutlookMessageFetcher } from "../fetchOutlookMessageSidecar"; -import type { DesktopSidecarClient } from "@erato/frontend/library"; 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 { @@ -15,14 +17,13 @@ function stubInner(): OutlookMessageFetcher { fetchMessageBytesByInternetMessageId: vi.fn(), fetchConversationMessages: vi.fn(async () => FALLBACK), fetchParentMessageInConversation: vi.fn(), - } as unknown as OutlookMessageFetcher; + }; } interface FakeClientOptions { supports?: boolean; mailboxes?: { id: string; emailAddress?: string }[]; conversation?: unknown; - transfer?: (handle: string) => Promise; } function defaultConversation() { @@ -37,7 +38,7 @@ function defaultConversation() { cc: [], sentAtUnixSeconds: 1_700_000_000, isDraft: false, - bodyHandle: { handle: "body01", contentType: "text/html", size: 6 }, + body: { contentType: "text/html", content: "

Hi

" }, attachments: [ { name: "doc.pdf", @@ -45,7 +46,7 @@ function defaultConversation() { size: 5, isInline: false, sha256: "a".repeat(64), - contentHandle: "att01", + contentBytes: ATTACHMENT_BYTES, }, ], }, @@ -58,7 +59,6 @@ function fakeClient(options: FakeClientOptions = {}): DesktopSidecarClient { supports = true, mailboxes = [{ id: "m1", emailAddress: "user@example.test" }], conversation = defaultConversation(), - transfer = async (handle: string) => new TextEncoder().encode(handle), } = options; return { supports: () => supports, @@ -67,7 +67,6 @@ function fakeClient(options: FakeClientOptions = {}): DesktopSidecarClient { if (method === "outlook.get_conversation.v1") return conversation; throw new Error(`unexpected invoke ${method}`); }, - fetchTransfer: (handle: string) => transfer(handle), } as unknown as DesktopSidecarClient; } @@ -81,7 +80,7 @@ function context(client: DesktopSidecarClient, inner: OutlookMessageFetcher) { } describe("createSidecarOutlookMessageFetcher", () => { - it("maps a conversation with fetchable body and attachment bytes", async () => { + it("maps a conversation with inline body and attachment bytes", async () => { const inner = stubInner(); const fetcher = createSidecarOutlookMessageFetcher( context(fakeClient(), inner), @@ -93,13 +92,13 @@ describe("createSidecarOutlookMessageFetcher", () => { expect(result.state).toBe("ok"); const message = result.messages[0]; expect(message.internetMessageId).toBe(""); - expect(message.body).toEqual({ contentType: "html", content: "body01" }); + 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).toBeDefined(); + expect(attachment.contentBytes).toBe(ATTACHMENT_BYTES); expect(atob(attachment.contentBytes!)).toBe("att01"); }); @@ -142,18 +141,36 @@ describe("createSidecarOutlookMessageFetcher", () => { expect(inner.fetchConversationMessages).toHaveBeenCalledOnce(); }); - it("degrades a failed attachment transfer to a marker + partial, not a full fallback", async () => { + 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({ - transfer: async (handle) => { - if (handle === "att01") throw new Error("transfer failed"); - return new TextEncoder().encode(handle); - }, - }), - inner, - ), + context(fakeClient({ conversation }), inner), ); const result = await fetcher.fetchConversationMessages("conv-1"); diff --git a/office-addin/src/utils/fetchOutlookMessageSidecar.ts b/office-addin/src/utils/fetchOutlookMessageSidecar.ts index 3e293d21f..0a93556f1 100644 --- a/office-addin/src/utils/fetchOutlookMessageSidecar.ts +++ b/office-addin/src/utils/fetchOutlookMessageSidecar.ts @@ -1,11 +1,3 @@ -import type { - DesktopSidecarClient, - OutlookBodyHandle, - OutlookConversationMessage, - OutlookAttachmentReference, - OutlookMessageRecipient, -} from "@erato/frontend/library"; - import type { OutlookMessageFetcher } from "./fetchOutlookMessage"; import type { FetchConversationOptions, @@ -15,12 +7,15 @@ import type { GraphConversationMessage, GraphRecipient, } from "./fetchOutlookMessageGraph"; +import type { + DesktopSidecarClient, + OutlookMessageBody, + OutlookConversationMessage, + OutlookAttachmentReference, + OutlookMessageRecipient, +} from "@erato/frontend/library"; const GET_CONVERSATION = "outlook.get_conversation.v1"; -/** Max simultaneous transfer fetches per level — messages, and attachments - * within a message — so the effective ceiling is this squared plus one body - * fetch per message. Loopback transfers, so the bound is generous by design. */ -const TRANSFER_CONCURRENCY = 5; /** 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"; @@ -36,8 +31,8 @@ export interface SidecarFetcherContext { userEmailAddress: string | null; } -/** Tracks whether any part of a conversation had to be degraded (a body or - * attachment whose bytes could not be fetched), so the result is `partial`. */ +/** 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; } @@ -51,9 +46,10 @@ interface ConversionProgress { * 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. Once the conversation is fetched, a single body or - * attachment transfer failure degrades just that item to a byte-less marker and - * marks the result `partial`, rather than discarding the whole thread. + * 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, @@ -110,10 +106,8 @@ async function fetchConversationViaSidecar( } const progress: ConversionProgress = { partial: conversation.state !== "ok" }; - const messages = await mapWithConcurrency( - conversation.messages, - TRANSFER_CONCURRENCY, - (message) => mapConversationMessage(client, message, signal, progress), + const messages = conversation.messages.map((message) => + mapConversationMessage(message, progress), ); return { messages, state: progress.partial ? "partial" : "ok" }; @@ -136,22 +130,10 @@ async function resolveMailboxId( return match?.id ?? null; } -async function mapConversationMessage( - client: DesktopSidecarClient, +function mapConversationMessage( message: OutlookConversationMessage, - signal: AbortSignal | undefined, progress: ConversionProgress, -): Promise { - const [body, attachments] = await Promise.all([ - mapBody(client, message.bodyHandle, signal, progress), - mapWithConcurrency( - message.attachments, - TRANSFER_CONCURRENCY, - (attachment, index) => - mapAttachment(client, attachment, index, signal, progress), - ), - ]); - +): GraphConversationMessage { return { internetMessageId: message.internetMessageId, subject: message.subject, @@ -161,43 +143,28 @@ async function mapConversationMessage( sentDateTime: toIsoDate(message.sentAtUnixSeconds), receivedDateTime: toIsoDate(message.receivedAtUnixSeconds), isDraft: message.isDraft, - body, - attachments, + body: mapBody(message.body), + attachments: message.attachments.map((attachment, index) => + mapAttachment(attachment, index, progress), + ), }; } -async function mapBody( - client: DesktopSidecarClient, - bodyHandle: OutlookBodyHandle | undefined, - signal: AbortSignal | undefined, - progress: ConversionProgress, -): Promise { - if (!bodyHandle) { - return undefined; - } - let bytes: Uint8Array; - try { - bytes = await client.fetchTransfer(bodyHandle.handle, { signal }); - } catch (error) { - if (signal?.aborted) { - throw error; - } - progress.partial = true; +function mapBody(body: OutlookMessageBody | undefined): GraphBody | undefined { + if (!body) { return undefined; } return { - contentType: bodyHandle.contentType.includes("html") ? "html" : "text", - content: new TextDecoder().decode(bytes), + contentType: body.contentType.includes("html") ? "html" : "text", + content: body.content, }; } -async function mapAttachment( - client: DesktopSidecarClient, +function mapAttachment( attachment: OutlookAttachmentReference, index: number, - signal: AbortSignal | undefined, progress: ConversionProgress, -): Promise { +): 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 @@ -213,21 +180,12 @@ async function mapAttachment( isInline: attachment.isInline, contentId: attachment.contentId, }; - if (!attachment.contentHandle) { - progress.partial = true; - return base; - } - let bytes: Uint8Array; - try { - bytes = await client.fetchTransfer(attachment.contentHandle, { signal }); - } catch (error) { - if (signal?.aborted) { - throw error; - } + if (attachment.contentBytes === undefined) { progress.partial = true; return base; } - return { ...base, contentBytes: bytesToBase64(bytes) }; + // The wire already carries base64, so it maps straight onto Graph's shape. + return { ...base, contentBytes: attachment.contentBytes }; } function toGraphRecipients( @@ -259,32 +217,3 @@ function toIsoDate(unixSeconds: number | undefined): string | undefined { ? undefined : new Date(unixSeconds * 1000).toISOString(); } - -function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - const chunkSize = 0x8000; - for (let index = 0; index < bytes.length; index += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); - } - return btoa(binary); -} - -async function mapWithConcurrency( - items: readonly T[], - limit: number, - map: (item: T, index: number) => Promise, -): Promise { - const results = new Array(items.length); - let next = 0; - const workers = Array.from( - { length: Math.min(limit, items.length) }, - async () => { - while (next < items.length) { - const current = next++; - results[current] = await map(items[current], current); - } - }, - ); - await Promise.all(workers); - return results; -} From 4a75077d00706064598e7346bbc861f958b5ab3d Mon Sep 17 00:00:00 2001 From: bdart Date: Thu, 30 Jul 2026 22:51:27 +0200 Subject: [PATCH 5/5] Expose the configured desktop sidecar endpoint to the add-in runtime --- office-addin/src/app/env.ts | 4 ++++ 1 file changed, 4 insertions(+) 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; }