Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions desktop-sidecar-protocol/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/library/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions office-addin/src/app/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions office-addin/src/components/__tests__/AddinChat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 19 additions & 5 deletions office-addin/src/hooks/useOutlookMessageFetcher.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<UseOutlookMessageFetcherResult>(() => {
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" };
Expand All @@ -75,5 +89,5 @@ export function useOutlookMessageFetcher(): UseOutlookMessageFetcherResult {
fetcher: createGraphOutlookMessageFetcher(acquireGraphToken),
unavailableReason: null,
};
}, [graph, mode, isOnPrem]);
}, [graph, mode, isOnPrem, sidecarClient, anchorInternetMessageId]);
}
184 changes: 184 additions & 0 deletions office-addin/src/utils/__tests__/fetchOutlookMessageSidecar.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<a@b>",
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: "<p>Hi</p>" },
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: "<a@b>",
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("<a@b>");
expect(message.body).toEqual({ contentType: "html", content: "<p>Hi</p>" });

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: "<a@b>",
subject: "Hello",
from: { name: "A", emailAddress: "a@example.test" },
to: [],
cc: [],
sentAtUnixSeconds: 1_700_000_000,
isDraft: false,
body: { contentType: "text/html", content: "<p>Hi</p>" },
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();
});
});
Loading