Skip to content

Commit 22da0e3

Browse files
committed
fix(outlook-mail,gmail): correct calendar-reply threading id + Message-ID sanitization
outlook-mail: sendCalendarEventReplyFn's subsequent-reply branch passed the stored internetMessageId to createReplyDraft, but that Graph endpoint requires a real item id, so every reply after the first 404'd (ErrorInvalidIdMalformed) and surfaced as a failed send. Resolve the actual target message from the conversation via getConversationMessages instead, falling back to starting a fresh conversation if it comes back empty. gmail: the seed Message-ID for a calendar-thread reply spliced the raw iCalUID into `<plot-cal-${iCalUID}-...@plot.day>`; since iCalUIDs are often themselves email-shaped (e.g. "abc@google.com"), this produced a malformed double-`@` Message-ID. Sanitize non-token characters in the local part only — the X-Plot-Event-UID header still carries the raw iCalUID.
1 parent 2c82849 commit 22da0e3

3 files changed

Lines changed: 61 additions & 22 deletions

File tree

‎connectors/gmail/src/sync.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1777,7 +1777,12 @@ export async function sendCalendarEventReplyFn(
17771777

17781778
let sentId: string;
17791779
if (!prior) {
1780-
const seedMessageId = `<plot-cal-${iCalUID}-${note.id}@plot.day>`;
1780+
// Sanitize only the Message-ID LOCAL PART: real iCalUIDs are often
1781+
// already an email-shaped string (e.g. "abc@google.com"), and splicing
1782+
// one in verbatim produces a double-`@` malformed Message-ID. The
1783+
// X-Plot-Event-UID header below still carries the raw iCalUID unchanged
1784+
// — only this synthetic Message-ID needs to be token-safe.
1785+
const seedMessageId = `<plot-cal-${iCalUID.replace(/[^A-Za-z0-9._-]/g, "-")}-${note.id}@plot.day>`;
17811786
const raw = buildNewEmailMessage({
17821787
to, cc, bcc, from, subject, body: note.content ?? "",
17831788
extraHeaders: [`Message-ID: ${seedMessageId}`, uidHeader],

‎connectors/outlook-mail/src/calendar-reply.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const { graphApi } = vi.hoisted(() => ({
66
createReplyDraft: vi.fn(),
77
updateMessage: vi.fn(),
88
getMessage: vi.fn(),
9+
getConversationMessages: vi.fn(),
910
send: vi.fn(),
1011
},
1112
}));
@@ -103,7 +104,15 @@ describe("outlook onNoteCreatedFn — calendar event thread", () => {
103104
expect(res).toEqual({ key: "<imid-1>" });
104105
});
105106

106-
it("second reply: threads via createReplyDraft into stored conversation", async () => {
107+
it("second reply: threads via createReplyDraft using the REAL Graph item id from the conversation, not the stored internetMessageId", async () => {
108+
// The prior code passed `prior.lastMessageId` (an RFC-822 Message-ID like
109+
// "<imid-1>") straight to createReplyDraft, which Graph rejects with a
110+
// 404 ErrorInvalidIdMalformed — createReplyDraft requires a real Graph
111+
// item id (e.g. "AAMk..."). The fix resolves the target message from the
112+
// conversation instead.
113+
graphApi.getConversationMessages.mockResolvedValue([
114+
{ id: "AAMk-real-id", isDraft: false, internetMessageId: "<imid-1>" },
115+
]);
107116
graphApi.createReplyDraft.mockResolvedValue({ id: "d2", internetMessageId: "<imid-2>" });
108117
graphApi.updateMessage.mockResolvedValue(undefined);
109118
graphApi.getMessage.mockResolvedValue({
@@ -113,12 +122,14 @@ describe("outlook onNoteCreatedFn — calendar event thread", () => {
113122
});
114123
graphApi.send.mockResolvedValue(undefined);
115124
const { host } = makeHost({
116-
"cal-reply:uid-123": { conversationId: "conv-1", lastMessageId: "seed-msg" },
125+
"cal-reply:uid-123": { conversationId: "conv-1", lastMessageId: "<imid-1>" },
117126
});
118127

119128
await onNoteCreatedFn(host, replyNote(["bob@x.com"]), calThread());
120129

121-
expect(graphApi.createReplyDraft).toHaveBeenCalledWith("seed-msg");
130+
expect(graphApi.getConversationMessages).toHaveBeenCalledWith("conv-1");
131+
expect(graphApi.createReplyDraft).toHaveBeenCalledWith("AAMk-real-id");
132+
expect(graphApi.createReplyDraft).not.toHaveBeenCalledWith("<imid-1>");
122133
expect(graphApi.send).toHaveBeenCalledWith("d2");
123134
});
124135
});

‎connectors/outlook-mail/src/sync.ts‎

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1865,8 +1865,7 @@ export async function sendCalendarEventReplyFn(
18651865
const stateKey = `cal-reply:${iCalUID}`;
18661866
const prior = await host.get<{ conversationId: string; lastMessageId: string }>(stateKey);
18671867

1868-
let draftId: string;
1869-
if (!prior) {
1868+
const sendFreshConversation = async (): Promise<NoteWriteBackResult | void> => {
18701869
const created = await api.createDraft({
18711870
subject: (thread.title as string) || "Event",
18721871
body: bodyHtml,
@@ -1875,7 +1874,7 @@ export async function sendCalendarEventReplyFn(
18751874
bccRecipients: bcc.map(addr),
18761875
internetMessageHeaders: [{ name: "x-plot-event-uid", value: iCalUID }],
18771876
});
1878-
draftId = created.id;
1877+
const draftId = created.id;
18791878
let imid = created.internetMessageId;
18801879
let conversationId = created.conversationId;
18811880
if (!imid || !conversationId) {
@@ -1889,22 +1888,46 @@ export async function sendCalendarEventReplyFn(
18891888
await host.set(stateKey, { conversationId, lastMessageId: key });
18901889
await host.set(`sent:${key}`, true);
18911890
return { key };
1892-
} else {
1893-
const draft = await api.createReplyDraft(prior.lastMessageId);
1894-
draftId = draft.id;
1895-
await api.updateMessage(draftId, {
1896-
body: bodyHtml,
1897-
toRecipients: to.map(addr),
1898-
ccRecipients: cc.map(addr),
1899-
...(bcc.length > 0 ? { bccRecipients: bcc.map(addr) } : {}),
1900-
});
1901-
const refreshed = await api.getMessage(draftId, "id,internetMessageId,conversationId");
1902-
const key = refreshed?.internetMessageId ?? draft.internetMessageId ?? draftId;
1903-
await api.send(draftId);
1904-
await host.set(stateKey, { conversationId: prior.conversationId, lastMessageId: key });
1905-
await host.set(`sent:${key}`, true);
1906-
return { key };
1891+
};
1892+
1893+
if (!prior) {
1894+
return sendFreshConversation();
1895+
}
1896+
1897+
// Subsequent reply: thread into the stored conversation via a REAL Graph
1898+
// item id (`createReplyDraft` → POST /me/messages/{id}/createReply
1899+
// requires the item id, not an internetMessageId — the prior code passed
1900+
// `prior.lastMessageId`, an RFC-822 Message-ID, which Graph rejects with a
1901+
// 404 ErrorInvalidIdMalformed on every reply after the first).
1902+
const conversationMessages = (
1903+
await api.getConversationMessages(prior.conversationId)
1904+
).filter((m) => !m.isDraft);
1905+
if (conversationMessages.length === 0) {
1906+
// Defensive: the stored conversation has no live messages to thread
1907+
// into (shouldn't normally happen — we sent a message there
1908+
// previously). Fall back to starting a fresh conversation rather than
1909+
// calling createReplyDraft with nothing to anchor to.
1910+
console.warn(
1911+
`[outlook-mail] calendar reply: conversation ${prior.conversationId} has no messages, starting a fresh one`
1912+
);
1913+
return sendFreshConversation();
19071914
}
1915+
const target = conversationMessages[conversationMessages.length - 1];
1916+
1917+
const draft = await api.createReplyDraft(target.id);
1918+
const draftId = draft.id;
1919+
await api.updateMessage(draftId, {
1920+
body: bodyHtml,
1921+
toRecipients: to.map(addr),
1922+
ccRecipients: cc.map(addr),
1923+
...(bcc.length > 0 ? { bccRecipients: bcc.map(addr) } : {}),
1924+
});
1925+
const refreshed = await api.getMessage(draftId, "id,internetMessageId,conversationId");
1926+
const key = refreshed?.internetMessageId ?? draft.internetMessageId ?? draftId;
1927+
await api.send(draftId);
1928+
await host.set(stateKey, { conversationId: prior.conversationId, lastMessageId: key });
1929+
await host.set(`sent:${key}`, true);
1930+
return { key };
19081931
}
19091932

19101933
/**

0 commit comments

Comments
 (0)