Skip to content
Merged
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
80 changes: 70 additions & 10 deletions connectors/google/src/mail/rsvp-note.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";

import type { CalendarReply } from "./gmail-api";
import { composeRsvpNote, shouldMarkUnread } from "./rsvp-note";
import { composeRsvpNote, shouldEmitRsvpNote, priorRsvpKey } from "./rsvp-note";

function reply(overrides: Partial<CalendarReply> = {}): CalendarReply {
return {
Expand DownExpand Up@@ -80,18 +80,78 @@ describe("composeRsvpNote", () => {
});
});

describe("shouldMarkUnread", () => {
it("marks declines and tentative responses unread", () => {
expect(shouldMarkUnread(reply({ partstat: "DECLINED" }), false)).toBe(true);
expect(shouldMarkUnread(reply({ partstat: "TENTATIVE" }), false)).toBe(true);
describe("shouldEmitRsvpNote", () => {
it("emits for a decline or a tentative", () => {
expect(shouldEmitRsvpNote(reply({ partstat: "DECLINED" }), false)).toBe(true);
expect(shouldEmitRsvpNote(reply({ partstat: "TENTATIVE" }), false)).toBe(true);
});

it("leaves read state untouched for an accept", () => {
expect(shouldMarkUnread(reply({ partstat: "ACCEPTED" }), false)).toBe(false);
it("suppresses a bare acceptance", () => {
expect(shouldEmitRsvpNote(reply({ partstat: "ACCEPTED" }), false)).toBe(false);
});

it("never marks unread during the initial backfill", () => {
expect(shouldMarkUnread(reply({ partstat: "DECLINED" }), true)).toBe(false);
expect(shouldMarkUnread(reply({ partstat: "TENTATIVE" }), true)).toBe(false);
it("emits an acceptance that carries a comment", () => {
expect(
shouldEmitRsvpNote(
reply({ partstat: "ACCEPTED", comment: "Sounds good, I'll bring the deck" }),
false
)
).toBe(true);
});

it("emits an acceptance that follows a decline or tentative", () => {
expect(shouldEmitRsvpNote(reply({ partstat: "ACCEPTED" }), true)).toBe(true);
});

it("treats an empty comment as no comment", () => {
expect(shouldEmitRsvpNote(reply({ partstat: "ACCEPTED", comment: "" }), false)).toBe(
false
);
});
});

describe("priorRsvpKey", () => {
it("scopes the key to the event and the attendee", () => {
expect(priorRsvpKey("uid-1@google.com", "beth@example.test", null)).toBe(
"rsvp:uid-1@google.com:series:beth@example.test"
);
});

it("normalises attendee case so a re-cased address hits the same key", () => {
expect(priorRsvpKey("uid-1@google.com", "Beth@Example.Test", null)).toBe(
priorRsvpKey("uid-1@google.com", "beth@example.test", null)
);
});

it("uses the literal 'series' segment for a series-wide reply", () => {
expect(priorRsvpKey("uid-1@google.com", "beth@example.test", null)).toBe(
"rsvp:uid-1@google.com:series:beth@example.test"
);
});

it("scopes the key to a single occurrence when the reply targets one", () => {
const occurrence = new Date("2026-08-04T14:00:00Z");
expect(priorRsvpKey("uid-1@google.com", "beth@example.test", occurrence)).toBe(
`rsvp:uid-1@google.com:${occurrence.toISOString()}:beth@example.test`
);
});

it("gives two different occurrences of the same uid+attendee different keys", () => {
const aug4 = new Date("2026-08-04T14:00:00Z");
const aug18 = new Date("2026-08-18T14:00:00Z");
const keyAug4 = priorRsvpKey("uid-1@google.com", "beth@example.test", aug4);
const keyAug18 = priorRsvpKey("uid-1@google.com", "beth@example.test", aug18);
expect(keyAug4).not.toBe(keyAug18);
});

it("gives an occurrence-scoped reply a different key from the series-wide key", () => {
const occurrence = new Date("2026-08-04T14:00:00Z");
const seriesKey = priorRsvpKey("uid-1@google.com", "beth@example.test", null);
const occurrenceKey = priorRsvpKey(
"uid-1@google.com",
"beth@example.test",
occurrence
);
expect(seriesKey).not.toBe(occurrenceKey);
});
});
53 changes: 41 additions & 12 deletions connectors/google/src/mail/rsvp-note.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,21 +55,50 @@ export function composeRsvpNote(reply: CalendarReply): string {
}

/**
* Whether this response should surface the event thread as unread.
* Whether an attendee response warrants a note on the event thread.
*
* A decline or a tentative response changes whether the meeting works and
* deserves attention. An acceptance tells the organizer nothing the event's
* guest list does not already show, so it must neither raise unread nor clear
* it — returning false here omits the flag entirely, which the runtime treats
* as "leave read state alone" (NOT as "mark read").
* A bare acceptance repeats what the event's guest list already shows, so it
* earns no note. That is also the only way to keep it from raising unread:
* attaching a note surfaces the thread as unread for every recipient except
* the note's author, and no field a connector passes to `saveNote` can
* suppress that. Writing nothing is the guarantee.
*
* The initial backfill never marks unread: folding a year of historical
* responses must not resurface old events.
* Everything else is genuinely new information and gets a note:
* a decline or a tentative changes whether the meeting works; an acceptance
* carrying a personal comment is a message from a person; and an acceptance
* that reverses an earlier decline or tentative is a real change of state,
* which `hadPriorNonAccept` reports from connector-local storage.
*/
export function shouldMarkUnread(
export function shouldEmitRsvpNote(
reply: CalendarReply,
initialSync: boolean
hadPriorNonAccept: boolean
): boolean {
if (initialSync) return false;
return reply.partstat === "DECLINED" || reply.partstat === "TENTATIVE";
if (reply.partstat !== "ACCEPTED") return true;
if (reply.comment) return true;
return hadPriorNonAccept;
}

/**
* Storage key holding an outstanding decline/tentative for one attendee on one
* event. Written when such a response is folded, cleared when that attendee
* later accepts — so the store only ever holds unresolved non-acceptances.
*
* Not read from `schedule_contact`: the calendar product's own attendee sync
* writes that same field from the event roster, so by the time an RSVP email is
* processed it may already read as accepted and the prior decline is gone. This
* key records what this connector last folded, which is the actual question.
*
* Scoped by `occurrence` as well as `uid`: a reply to one occurrence of a
* recurring event carries the same series `uid` as every other occurrence,
* distinguished only by `RECURRENCE-ID`. Without the occurrence in the key, a
* decline on one occurrence would be read as an outstanding non-acceptance for
* an unrelated occurrence's later reply. `null` (a series-wide response) maps
* to the literal `"series"` segment.
*/
export function priorRsvpKey(
uid: string,
attendeeEmail: string,
occurrence: Date | null
): string {
return `rsvp:${uid}:${occurrence ? occurrence.toISOString() : "series"}:${attendeeEmail.toLowerCase()}`;
}
146 changes: 135 additions & 11 deletions connectors/google/src/mail/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import {
REACTION_SEND_DELAY_MS,
sendReactionEmailFn,
} from "./sync";
import { priorRsvpKey } from "./rsvp-note";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
Expand DownExpand Up@@ -935,7 +936,7 @@ describe("processEmailThreadsFn — calendar-thread bundling", () => {
/** ICS body for one attendee response. */
function replyIcs(
partstat: "DECLINED" | "ACCEPTED" | "TENTATIVE",
opts: { uid?: string; comment?: string } = {}
opts: { uid?: string; comment?: string; recurrenceId?: string } = {}
): string {
const lines = [
"BEGIN:VCALENDAR",
Expand All@@ -945,6 +946,7 @@ function replyIcs(
`ATTENDEE;PARTSTAT=${partstat};CN=Beth Round:mailto:beth@example.test`,
];
if (opts.comment) lines.push(`COMMENT:${opts.comment}`);
if (opts.recurrenceId) lines.push(`RECURRENCE-ID:${opts.recurrenceId}`);
lines.push("END:VEVENT", "END:VCALENDAR");
return lines.join("\r\n");
}
Expand DownExpand Up@@ -1068,7 +1070,7 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", ()
);
});

it("omits unread entirely for an acceptance", async () => {
it("writes no note at all for a bare acceptance, and saves no email link", async () => {
const { host } = makeHost();
const { notes, links } = captureSaves(host);

Expand All@@ -1079,24 +1081,121 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", ()
"INBOX"
);

expect(notes[0].content).toBe("Beth Round accepted.");
// Not `unread: false` — that would CLEAR unread the event already had.
expect(notes[0]).not.toHaveProperty("unread");
// The guest list already shows the acceptance. Writing a note is the only
// thing that could mark the organiser's event thread unread, so we write none.
expect(notes).toHaveLength(0);
expect(links).toHaveLength(0);
});

it("omits unread for every response during the initial backfill", async () => {
it("writes a note for an acceptance carrying a personal comment", async () => {
const { host } = makeHost();
const { notes } = captureSaves(host);

await processEmailThreadsFn(
host,
[rsvpThread("rsvp-backfill", replyIcs("DECLINED"))],
[
rsvpThread(
"rsvp-accepted-comment",
replyIcs("ACCEPTED", { comment: "Sounds good, I'll bring the deck" })
),
],
false,
"INBOX"
);

expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({
content: "Beth Round accepted.\n\n> Sounds good, I'll bring the deck",
unread: true,
});
});

it("writes a note when an acceptance reverses an earlier decline", async () => {
const { host, store } = makeHost();
const { notes } = captureSaves(host);

await processEmailThreadsFn(
host,
[rsvpThread("rsvp-declined", replyIcs("DECLINED"))],
false,
"INBOX"
);
const key = priorRsvpKey("uid-rsvp@google.com", "beth@example.test", null);
expect(store.get(key)).toBe("DECLINED");

await processEmailThreadsFn(
host,
[rsvpThread("rsvp-accepted", replyIcs("ACCEPTED"))],
false,
"INBOX"
);

// Two notes: the decline, then the reversal.
expect(notes).toHaveLength(2);
expect(notes[1]).toMatchObject({ content: "Beth Round accepted." });
// The outstanding non-acceptance is resolved, so the key is gone.
expect(store.has(key)).toBe(false);
});

it("does not let a decline on one occurrence suppress an acceptance on another", async () => {
const { host, store } = makeHost();
const { notes } = captureSaves(host);

// Beth declines the Aug 4 occurrence of a recurring standup.
await processEmailThreadsFn(
host,
[
rsvpThread(
"rsvp-recurring-declined",
replyIcs("DECLINED", { recurrenceId: "20260804T140000Z" })
),
],
false,
"INBOX"
);
expect(notes).toHaveLength(1);
const aug4Key = priorRsvpKey(
"uid-rsvp@google.com",
"beth@example.test",
new Date("2026-08-04T14:00:00Z")
);
expect(store.get(aug4Key)).toBe("DECLINED");

// Two weeks later she bare-accepts a different occurrence of the same
// series (same UID, different RECURRENCE-ID). The Aug 4 decline must not
// be read as an outstanding non-acceptance for the Aug 18 occurrence.
await processEmailThreadsFn(
host,
[
rsvpThread(
"rsvp-recurring-accepted",
replyIcs("ACCEPTED", { recurrenceId: "20260818T140000Z" })
),
],
false,
"INBOX"
);

// No second note: the bare acceptance on the Aug 18 occurrence stays
// suppressed, and the Aug 4 decline's key is untouched.
expect(notes).toHaveLength(1);
expect(store.get(aug4Key)).toBe("DECLINED");
});

it("marks a folded response read during the initial backfill", async () => {
const { host } = makeHost();
const { notes } = captureSaves(host);

await processEmailThreadsFn(
host,
[rsvpThread("rsvp-declined", replyIcs("DECLINED"))],
true,
"INBOX"
);

expect(notes[0]).not.toHaveProperty("unread");
// Explicit false, not an omitted flag: a note already surfaces as unread
// by the time this field is read, so only an explicit false overrides it.
expect(notes[0]).toMatchObject({ unread: false });
});

it("keeps ordinary correspondence in its own email thread", async () => {
Expand DownExpand Up@@ -1191,9 +1290,17 @@ describe("drainPendingRsvpsFn — retract once the event arrives", () => {
function seedPending(
host: GmailSyncHost,
store: Map<string, unknown>,
opts: { firstSeen?: string } = {}
opts: {
firstSeen?: string;
partstat?: "DECLINED" | "ACCEPTED" | "TENTATIVE";
} = {}
) {
const gmailThread = rsvpThread("rsvp-late", replyIcs("ACCEPTED"));
// Declined by default: a bare acceptance now writes no note, so it cannot
// exercise the fold-and-retract path these tests cover.
const gmailThread = rsvpThread(
"rsvp-late",
replyIcs(opts.partstat ?? "DECLINED")
);
store.set("pending-rsvp:rsvp-late", {
threadId: "rsvp-late",
channelId: "INBOX",
Expand DownExpand Up@@ -1264,7 +1371,9 @@ describe("drainPendingRsvpsFn — retract once the event arrives", () => {

await drainPendingRsvpsFn(host);

expect(notes[0].unread).toBeUndefined();
// Explicit false, not an omitted flag: omitting leaves the scoped-note
// trigger's unread standing, same convention as the live fold path.
expect(notes[0]).toMatchObject({ unread: false });
});

it("keeps the entry and archives nothing while the event is still missing", async () => {
Expand All@@ -1278,6 +1387,21 @@ describe("drainPendingRsvpsFn — retract once the event arrives", () => {
expect(store.has("pending-rsvp:rsvp-late")).toBe(true);
});

it("writes no note when the deferred response was a bare acceptance", async () => {
const { host, store } = makeHost();
const { notes } = captureSaves(host, { noteId: "N" });
seedPending(host, store, { partstat: "ACCEPTED" });

await drainPendingRsvpsFn(host);

expect(notes).toHaveLength(0);
// Still retracted: nothing was left for the standalone email thread to show.
expect(host.tools.integrations.archiveLinks).toHaveBeenCalledWith({
meta: { threadId: "rsvp-late" },
});
expect(store.has("pending-rsvp:rsvp-late")).toBe(false);
});

it("gives up on an entry older than the retry window", async () => {
const { host, store } = makeHost();
captureSaves(host, { noteId: "N" });
Expand Down
Loading
Loading