Skip to content

Commit a33dcd5

Browse files
KrisBraunclaude
andauthored
feat(google): two-way Gmail emoji reactions (#335)
* fix(google): fold Gmail emoji reactions into the reacted message Gmail emoji reactions are delivered as their own email carrying a `text/vnd.google.email-reaction+json` MIME part (plus text/plain and text/html fallbacks) and an `In-Reply-To` header naming the reacted message. The Gmail mail sync treated these like any other message, so a reaction surfaced as a standalone "<emoji> — reacted via Gmail" note in the thread. `transformGmailThread` now detects reaction emails by that MIME part, reads the emoji from its JSON body, resolves the reacted message via `In-Reply-To` → RFC `Message-ID` → the note built for that message, and records the reactor on that note's `reactions` instead of emitting a note. When the reacted message can't be resolved (missing In-Reply-To, or it isn't in the fetched thread) the reaction is kept as an ordinary note so nothing is lost — mirroring Gmail's own fallback. Following the platform convention that a note's synced `reactions` declares its complete reaction state, the set is reconstructed from the full thread on every sync and only set on notes that carry a reaction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(google): send Gmail emoji reactions added in Plot back to Gmail Completes the emoji-reaction round-trip. When a user reacts to a Gmail message note in Plot, the connector now emits a Gmail emoji-reaction email on that thread, so recipients using Gmail see a native reaction on the message and everyone else receives the text/plain + text/html fallback — the same experience as reacting in Gmail directly. - `reactionCapabilities: { mode: "open-unicode", customEmoji: "none" }` on the email link type: any single Unicode emoji is accepted; workspace custom emoji are left Plot-only. - `onNoteReactionChanged` builds a `text/vnd.google.email-reaction+json` email (via `buildReactionMessage`) threaded on the reacted message (In-Reply-To → its RFC Message-ID) and reply-all to the other participants, sent via the reacting user's own connection. - Send-undo window: the send is deferred behind a keyed one-shot task, so removing or changing the reaction within the window cancels or replaces it before anything is emailed (a change = remove-old + add-new lands on separate keys, so only the final emoji sends). - Removal after send best-effort trashes our own reaction email so the sender's mailbox reconverges on the next sync (recipients already delivered keep it — an inherent limit of email reactions). - Idempotency guard (`reaction-msg:<key>:<emoji>`) makes a re-dispatched or re-synced reaction a no-op, so the inbound fold can't trigger a duplicate send. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(google): HTML-escape the emoji in the reaction email's html fallback The reaction email's text/html fallback interpolated the emoji directly into markup. A real emoji never contains HTML metacharacters, but the value is ultimately a user-supplied reaction string (only custom-emoji refs are pre-filtered, not arbitrary content), so escape it to keep the outbound email's HTML body free of markup injection. The text/plain and JSON reaction parts were already safe (base64 / JSON-encoded). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2cc6173 commit a33dcd5

6 files changed

Lines changed: 846 additions & 2 deletions

File tree

‎connectors/google/src/google.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
initialSyncBatchFn,
3232
incrementalSyncBatchFn,
3333
onNoteCreatedFn,
34+
onNoteReactionChangedFn,
35+
sendReactionEmailFn,
3436
onThreadReadFn,
3537
onThreadToDoFn,
3638
onCreateLinkFn,
@@ -649,6 +651,15 @@ export class Google extends Connector<Google> {
649651
self.mailScheduleRenewal(expiration),
650652
scheduleSelfHealCheck: () => self.mailScheduleSelfHeal(),
651653
cancelScheduledTask: (key) => self.cancelScheduledTask(key),
654+
scheduleReactionSend: (key, threadId, channelId, noteKey, emoji, runAt) =>
655+
self.mailScheduleReactionSend(
656+
key,
657+
threadId,
658+
channelId,
659+
noteKey,
660+
emoji,
661+
runAt
662+
),
652663
queueIncrementalSync: () => self.mailQueueIncrementalSync(),
653664
queueWriteBackRetry: () => self.mailQueueWriteBackRetry(),
654665
},
@@ -841,6 +852,61 @@ export class Google extends Connector<Google> {
841852
return onNoteCreatedFn(this.makeMailHost(), note, thread);
842853
}
843854

855+
/**
856+
* A user added/removed an emoji reaction on a note. Dispatched on the
857+
* reacting user's own instance, so the reaction email is sent as them.
858+
* Delegates to the mail module, which schedules the deferred send (add) or
859+
* cancels/retracts it (remove).
860+
*/
861+
async onNoteReactionChanged(
862+
note: Note,
863+
thread: Thread,
864+
_actor: Actor,
865+
emoji: string,
866+
added: boolean
867+
): Promise<void> {
868+
await onNoteReactionChangedFn(this.makeMailHost(), note, thread, emoji, added);
869+
}
870+
871+
/**
872+
* Schedule the deferred reaction send (host scheduler hook). Keyed one-shot
873+
* via `scheduleTask` so a quick remove/change cancels or replaces it before
874+
* it fires (send-undo style).
875+
*/
876+
private async mailScheduleReactionSend(
877+
key: string,
878+
threadId: string,
879+
channelId: string,
880+
noteKey: string,
881+
emoji: string,
882+
runAt: Date
883+
): Promise<void> {
884+
const callback = await this.callback(
885+
this.mailSendReaction,
886+
threadId,
887+
channelId,
888+
noteKey,
889+
emoji
890+
);
891+
await this.scheduleTask(key, callback, { runAt });
892+
}
893+
894+
/** Deferred reaction send callback (fires after the undo window). */
895+
async mailSendReaction(
896+
threadId: string,
897+
channelId: string,
898+
noteKey: string,
899+
emoji: string
900+
): Promise<void> {
901+
await sendReactionEmailFn(
902+
this.makeMailHost(),
903+
threadId,
904+
channelId,
905+
noteKey,
906+
emoji
907+
);
908+
}
909+
844910
async onThreadRead(
845911
thread: Thread,
846912
actor: Actor,

‎connectors/google/src/mail/channels.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [
3636
{ id: "bcc", label: "BCC", hidden: true },
3737
],
3838
supportsContactChanges: true,
39+
// Gmail emoji reactions round-trip: inbound reactions fold onto the reacted
40+
// note (transformGmailThread), and reactions added in Plot are sent back as
41+
// Gmail reaction emails (onNoteReactionChanged). Gmail accepts any single
42+
// Unicode emoji but no workspace custom emoji.
43+
reactionCapabilities: { mode: "open-unicode", customEmoji: "none" },
3944
// Gmail composes target any address — a Plot contact (with or without
4045
// a Gmail-connection row) or a free-form typed email delivered via
4146
// `inviteEmails`. The runtime fills `recipients` from the

‎connectors/google/src/mail/gmail-api.test.ts‎

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import type { NewReactions } from "@plotday/twister/plot";
23
import {
34
GmailApi,
45
buildForwardMessage,
56
buildNewEmailMessage,
7+
buildReactionMessage,
68
buildReplyMessage,
79
canonicalizeGmailAddress,
810
classifyCalendarThread,
911
formatFromHeader,
12+
isSendableGmailReaction,
1013
stripQuotedReply,
1114
transformGmailThread,
1215
type AttachmentData,
@@ -652,6 +655,194 @@ describe("transformGmailThread sender classification", () => {
652655
});
653656
});
654657

658+
describe("buildReactionMessage", () => {
659+
const build = () =>
660+
decodeRawMessage(
661+
buildReactionMessage({
662+
to: ["alice@example.com"],
663+
cc: ["carol@example.com"],
664+
from: '"Me" <me@example.com>',
665+
subject: "Welcome Wael",
666+
emoji: "💖",
667+
messageId: "<orig@mail.gmail.com>",
668+
references: "<root@mail.gmail.com>",
669+
})
670+
);
671+
672+
it("threads the reaction onto the reacted message via In-Reply-To/References", () => {
673+
const raw = build();
674+
expect(raw).toContain("In-Reply-To: <orig@mail.gmail.com>");
675+
expect(raw).toContain(
676+
"References: <root@mail.gmail.com> <orig@mail.gmail.com>"
677+
);
678+
expect(raw).toContain("Subject: Re: Welcome Wael");
679+
expect(raw).toContain("To: alice@example.com");
680+
expect(raw).toContain("Cc: carol@example.com");
681+
});
682+
683+
it("carries a text/vnd.google.email-reaction+json part with the emoji", () => {
684+
const raw = build();
685+
expect(raw).toContain("Content-Type: text/vnd.google.email-reaction+json");
686+
const json = decodeMimePart(raw, "text/vnd.google.email-reaction+json");
687+
expect(JSON.parse(json)).toEqual({ version: 1, emoji: "💖" });
688+
});
689+
690+
it("places the reaction part between the text/plain and text/html fallbacks", () => {
691+
const raw = build();
692+
const plainIdx = raw.indexOf("text/plain");
693+
const reactionIdx = raw.indexOf("text/vnd.google.email-reaction+json");
694+
const htmlIdx = raw.indexOf("text/html");
695+
expect(plainIdx).toBeGreaterThanOrEqual(0);
696+
expect(reactionIdx).toBeGreaterThan(plainIdx);
697+
expect(htmlIdx).toBeGreaterThan(reactionIdx);
698+
// The plain-text fallback is the bare emoji so non-supporting clients
699+
// still show something.
700+
expect(decodeMimePart(raw, "text/plain")).toBe("💖");
701+
});
702+
703+
it("HTML-escapes the emoji in the html fallback (no markup injection)", () => {
704+
const raw = decodeRawMessage(
705+
buildReactionMessage({
706+
to: ["alice@example.com"],
707+
cc: [],
708+
from: "me@example.com",
709+
subject: "Hi",
710+
emoji: "<img src=x onerror=alert(1)>",
711+
messageId: "<orig@mail.gmail.com>",
712+
references: "",
713+
})
714+
);
715+
const html = decodeMimePart(raw, "text/html");
716+
expect(html).not.toContain("<img src=x");
717+
expect(html).toContain("&lt;img src=x onerror=alert(1)&gt;");
718+
});
719+
});
720+
721+
describe("isSendableGmailReaction", () => {
722+
it("accepts a bare Unicode emoji", () => {
723+
expect(isSendableGmailReaction("💖")).toBe(true);
724+
expect(isSendableGmailReaction("👍")).toBe(true);
725+
});
726+
727+
it("rejects workspace custom-emoji refs and empty input", () => {
728+
expect(isSendableGmailReaction("slack:T0123/party_parrot")).toBe(false);
729+
expect(isSendableGmailReaction("")).toBe(false);
730+
});
731+
});
732+
733+
describe("transformGmailThread emoji reactions", () => {
734+
/** Build a GmailMessage with the given id, top-level headers, and payload. */
735+
function reactionMsg(opts: {
736+
id: string;
737+
headers: Array<[string, string]>;
738+
payload: GmailMessagePart;
739+
internalDate?: string;
740+
}): GmailMessage {
741+
return {
742+
id: opts.id,
743+
threadId: "thread-1",
744+
labelIds: ["INBOX"],
745+
snippet: "snippet",
746+
historyId: "1",
747+
internalDate: opts.internalDate ?? "1700000000000",
748+
payload: {
749+
...opts.payload,
750+
headers: [
751+
...opts.headers.map(([name, value]) => ({ name, value })),
752+
...opts.payload.headers,
753+
],
754+
},
755+
sizeEstimate: 1000,
756+
};
757+
}
758+
759+
/** A Gmail emoji-reaction email per the text/vnd.google.email-reaction+json spec. */
760+
function reactionPayload(emoji: string): GmailMessagePart {
761+
return part("multipart/alternative", {
762+
parts: [
763+
part("text/plain", { data: emoji }),
764+
part("text/vnd.google.email-reaction+json", {
765+
data: JSON.stringify({ version: 1, emoji }),
766+
}),
767+
part("text/html", {
768+
data: `<div>${emoji}<br>Jennifer reacted via <a href="https://www.google.com/gmail/about/?utm_campaign=emojireactionemail#app">Gmail</a></div>`,
769+
}),
770+
],
771+
});
772+
}
773+
774+
it("folds a reaction email into the reacted note instead of creating a new note", () => {
775+
const original = reactionMsg({
776+
id: "msg-orig",
777+
headers: [
778+
["From", "Alice <alice@example.com>"],
779+
["Message-ID", "<orig@mail.gmail.com>"],
780+
["Subject", "Welcome Wael"],
781+
],
782+
payload: part("text/plain", { data: "Welcome aboard!" }),
783+
});
784+
const reaction = reactionMsg({
785+
id: "msg-react",
786+
internalDate: "1700000100000",
787+
headers: [
788+
["From", "Jennifer <jennifer@example.com>"],
789+
["In-Reply-To", "<orig@mail.gmail.com>"],
790+
["Subject", "Re: Welcome Wael"],
791+
],
792+
payload: reactionPayload("💖"),
793+
});
794+
const t: GmailThread = {
795+
id: "thread-1",
796+
historyId: "1",
797+
messages: [original, reaction],
798+
};
799+
800+
const link = transformGmailThread(t);
801+
802+
// Only the original message becomes a note — the reaction is folded in.
803+
expect(link.notes).toHaveLength(1);
804+
const note = link.notes![0] as {
805+
key?: string;
806+
content?: string;
807+
reactions?: NewReactions;
808+
};
809+
expect(note.key).toBe("msg-orig");
810+
// No note carries the reaction email's "reacted via Gmail" body.
811+
expect(
812+
link.notes!.some((n) => (n.content ?? "").includes("reacted via"))
813+
).toBe(false);
814+
// The reactor is recorded as a reaction on the reacted note.
815+
const reactors = note.reactions?.["💖"] as
816+
| Array<{ email?: string }>
817+
| undefined;
818+
expect(reactors).toHaveLength(1);
819+
expect(reactors![0].email).toBe("jennifer@example.com");
820+
});
821+
822+
it("keeps the reaction as a normal note when the reacted message is absent", () => {
823+
// In-Reply-To points at a message not present in the fetched thread —
824+
// fall back to the pre-existing behavior so the reaction isn't lost.
825+
const reaction = reactionMsg({
826+
id: "msg-react",
827+
headers: [
828+
["From", "Jennifer <jennifer@example.com>"],
829+
["In-Reply-To", "<missing@mail.gmail.com>"],
830+
["Subject", "Re: Welcome Wael"],
831+
],
832+
payload: reactionPayload("💖"),
833+
});
834+
const t: GmailThread = {
835+
id: "thread-1",
836+
historyId: "1",
837+
messages: [reaction],
838+
};
839+
840+
const link = transformGmailThread(t);
841+
expect(link.notes).toHaveLength(1);
842+
expect((link.notes![0] as { key?: string }).key).toBe("msg-react");
843+
});
844+
});
845+
655846
describe("classifyCalendarThread", () => {
656847
const icsUpdate =
657848
"BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:uid-1\r\nSEQUENCE:2\r\nEND:VEVENT\r\nEND:VCALENDAR";

0 commit comments

Comments
 (0)