Skip to content

Commit 9efdfa8

Browse files
KrisBraunclaude
andcommitted
fix(gmail): exclude dot/plus variants of your own address from reply-all
Gmail ignores dots and anything after "+" in the local part of an address, so mail addressed to a dotless or tagged variant of your own address (e.g. "johndoe@gmail.com" vs the connected "john.doe@gmail.com") lands in the same inbox. Replying to such a thread compared addresses literally, so the variant wasn't recognized as "you" and was included as an outbound recipient — sending you your own reply as if it were a new message from someone else. Reply recipient resolution now canonicalizes Gmail/Googlemail addresses (lowercase, dots and +tag stripped) before checking whether a header address belongs to the connected mailbox. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bZaWo6FPSsSNL5MhE5umC
1 parent 5f15437 commit 9efdfa8

4 files changed

Lines changed: 153 additions & 3 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildForwardMessage,
55
buildNewEmailMessage,
66
buildReplyMessage,
7+
canonicalizeGmailAddress,
78
classifyCalendarThread,
89
formatFromHeader,
910
stripQuotedReply,
@@ -669,3 +670,36 @@ describe("classifyCalendarThread", () => {
669670
expect(classifyCalendarThread([msgWithIcs(icsReply)])).toBeNull();
670671
});
671672
});
673+
674+
describe("canonicalizeGmailAddress", () => {
675+
it("strips dots from the local part of a gmail.com address", () => {
676+
expect(canonicalizeGmailAddress("kris.braun@gmail.com")).toBe(
677+
"krisbraun@gmail.com"
678+
);
679+
expect(canonicalizeGmailAddress("krisbraun@gmail.com")).toBe(
680+
"krisbraun@gmail.com"
681+
);
682+
});
683+
684+
it("strips a +tag suffix from the local part", () => {
685+
expect(canonicalizeGmailAddress("kris.braun+updates@gmail.com")).toBe(
686+
"krisbraun@gmail.com"
687+
);
688+
});
689+
690+
it("normalizes googlemail.com to the same canonical form as gmail.com", () => {
691+
expect(canonicalizeGmailAddress("kris.braun@googlemail.com")).toBe(
692+
"krisbraun@gmail.com"
693+
);
694+
});
695+
696+
it("lowercases but otherwise leaves non-Gmail domains untouched", () => {
697+
expect(canonicalizeGmailAddress("Kris.Braun@Example.com")).toBe(
698+
"kris.braun@example.com"
699+
);
700+
});
701+
702+
it("returns the lowercased input unchanged when there's no @", () => {
703+
expect(canonicalizeGmailAddress("not-an-email")).toBe("not-an-email");
704+
});
705+
});

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,27 @@ export function splitEmailList(headerValue: string): string[] {
536536
return parts;
537537
}
538538

539+
/**
540+
* Canonicalizes a Gmail/Googlemail address for self-identity comparison.
541+
* Gmail treats dots in the local part and anything after a `+` as
542+
* insignificant for delivery — `kris.braun@gmail.com`, `krisbraun@gmail.com`,
543+
* and `kris.braun+tag@gmail.com` all route to the same mailbox — but message
544+
* headers preserve whichever exact variant was used to address the mail. A
545+
* literal string comparison against the account's canonical address (as
546+
* returned by the Gmail API) therefore misses these variants, letting the
547+
* user's own address survive into an outbound reply-all as if it were a
548+
* distinct recipient. Non-Gmail domains are returned lowercased, unchanged.
549+
*/
550+
export function canonicalizeGmailAddress(email: string): string {
551+
const lower = email.trim().toLowerCase();
552+
const at = lower.lastIndexOf("@");
553+
if (at === -1) return lower;
554+
const domain = lower.slice(at + 1);
555+
if (domain !== "gmail.com" && domain !== "googlemail.com") return lower;
556+
const local = lower.slice(0, at).split("+")[0]!.replace(/\./g, "");
557+
return `${local}@gmail.com`;
558+
}
559+
539560
/**
540561
* Parses a comma-separated email header value into an array of email address strings.
541562
* Skips entries that are not valid email addresses (e.g. "undisclosed-recipients:;").

‎connectors/google/src/mail/sync.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,86 @@ describe("onNoteCreatedFn — calendar event thread", () => {
446446
});
447447
});
448448

449+
/** A plain (non-calendar) Gmail thread whose sole message addressed the
450+
* connected mailbox via a dot-variant of its own address. */
451+
function gmailAliasReplyThread(): GmailThread {
452+
const message: GmailMessage = {
453+
id: "msg-orig-1",
454+
threadId: "gmail-thread-1",
455+
labelIds: ["INBOX"],
456+
snippet: "Hi Kris",
457+
historyId: "1",
458+
internalDate: "1700000000000",
459+
sizeEstimate: 100,
460+
payload: {
461+
mimeType: "text/plain",
462+
headers: [
463+
{ name: "Message-ID", value: "<orig@mail.gmail.com>" },
464+
{ name: "From", value: "Hilary Collier <hilary.collier@example.com>" },
465+
{ name: "To", value: "krisbraun@gmail.com" },
466+
{ name: "Cc", value: "annie@example.com" },
467+
{ name: "Subject", value: "Surprise Tribute Video" },
468+
],
469+
body: { size: 10, data: b64url("Hi Kris") },
470+
},
471+
};
472+
return { id: "gmail-thread-1", historyId: "1", messages: [message] };
473+
}
474+
475+
function plainThread(over: Record<string, unknown> = {}) {
476+
return {
477+
id: "T2",
478+
title: "Surprise Tribute Video",
479+
meta: { channelId: "INBOX", threadId: "gmail-thread-1" },
480+
accessContacts: [
481+
{ id: "c-me", email: "kris.braun@gmail.com" },
482+
{ id: "c-hilary", email: "hilary.collier@example.com" },
483+
{ id: "c-annie", email: "annie@example.com" },
484+
],
485+
...over,
486+
} as unknown as import("@plotday/twister").Thread;
487+
}
488+
489+
function plainReplyNote(over: Record<string, unknown> = {}) {
490+
return {
491+
id: "n2",
492+
author: { id: "c-me" },
493+
content: "Sounds good!",
494+
recipients: null,
495+
accessContacts: null,
496+
actions: [],
497+
...over,
498+
} as unknown as import("@plotday/twister").Note;
499+
}
500+
501+
describe("onNoteCreatedFn — plain Gmail thread reply-all", () => {
502+
it("excludes the connected mailbox's own dot-variant alias address from the outbound recipients", async () => {
503+
vi.spyOn(GmailApi.prototype, "getThread").mockResolvedValue(
504+
gmailAliasReplyThread()
505+
);
506+
// The account's canonical/connected address (with dot) never literally
507+
// matches the alias form the original message was addressed to
508+
// (krisbraun@gmail.com) — Gmail treats both as the same mailbox.
509+
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
510+
emailAddress: "kris.braun@gmail.com",
511+
});
512+
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
513+
email: "kris.braun@gmail.com",
514+
});
515+
const send = vi
516+
.spyOn(GmailApi.prototype, "sendMessage")
517+
.mockResolvedValue({ id: "sent-9", threadId: "gmail-thread-1" });
518+
const { host } = makeHost();
519+
520+
await onNoteCreatedFn(host, plainReplyNote(), plainThread());
521+
522+
expect(send).toHaveBeenCalledTimes(1);
523+
const raw = decodeRawMessage(send.mock.calls[0][0]);
524+
expect(raw).not.toContain("krisbraun@gmail.com");
525+
expect(raw).toContain("hilary.collier@example.com");
526+
});
527+
});
528+
449529
/** Build a GmailMessagePart, encoding `data` as base64url like the real API. */
450530
function part(
451531
mimeType: string,

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
buildForwardMessage,
4848
buildNewEmailMessage,
4949
buildReplyMessage,
50+
canonicalizeGmailAddress,
5051
classifyCalendarThread,
5152
collectAttachments,
5253
extractBody,
@@ -1694,6 +1695,18 @@ export async function onNoteCreatedFn(
16941695
)?.email;
16951696
if (authorEmail) selfEmails.add(authorEmail.toLowerCase());
16961697

1698+
// Gmail ignores dots and anything after "+" in the local part, so a header
1699+
// may address the user via a variant that never string-matches selfEmails
1700+
// (e.g. "krisbraun@gmail.com" vs the connected "kris.braun@gmail.com").
1701+
// Compare header candidates against the canonical form of every self
1702+
// address, in addition to the exact-match `selfEmails` set the shared
1703+
// helper below still uses for the access-contact/reply-all cases.
1704+
const selfCanonical = new Set(
1705+
Array.from(selfEmails, canonicalizeGmailAddress)
1706+
);
1707+
const isSelfAddress = (email: string) =>
1708+
selfEmails.has(email) || selfCanonical.has(canonicalizeGmailAddress(email));
1709+
16971710
// Fallback access constraint (used only when the runtime didn't resolve
16981711
// note.recipients): resolve the note's access list to lowercased emails.
16991712
let accessContactEmails: Set<string> | null = null;
@@ -1707,7 +1720,9 @@ export async function onNoteCreatedFn(
17071720
}
17081721
}
17091722

1710-
// Original-message participants: From ∪ To → To, Cc → Cc.
1723+
// Original-message participants: From ∪ To → To, Cc → Cc. Self-address
1724+
// variants (see isSelfAddress above) are dropped here so they never reach
1725+
// the shared recipient resolver below.
17111726
const fromToCandidates = new Set<string>();
17121727
for (const email of parseEmailAddresses(fromHeader)) {
17131728
fromToCandidates.add(email.toLowerCase());
@@ -1720,7 +1735,7 @@ export async function onNoteCreatedFn(
17201735
ccCandidates.add(email.toLowerCase());
17211736
}
17221737
const toCandidates = Array.from(fromToCandidates).filter(
1723-
(email) => !ccCandidates.has(email)
1738+
(email) => !ccCandidates.has(email) && !isSelfAddress(email)
17241739
);
17251740

17261741
// Resolve the outbound recipients via the shared helper: prefer the runtime's
@@ -1731,7 +1746,7 @@ export async function onNoteCreatedFn(
17311746
recipients: note.recipients ?? null,
17321747
accessContactEmails,
17331748
headerTo: toCandidates,
1734-
headerCc: Array.from(ccCandidates),
1749+
headerCc: Array.from(ccCandidates).filter((email) => !isSelfAddress(email)),
17351750
selfEmails,
17361751
});
17371752

0 commit comments

Comments
 (0)