Skip to content

Commit a7c3cad

Browse files
KrisBraunclaude
andcommitted
fix(google): allow the userinfo endpoint so sends carry a display name
The composite Google connector reuses the Gmail send path, which resolves the account's display name from Google's userinfo endpoint to build a `"Name" <email>` From header. That endpoint was missing from this connector's own Network allowlist, so the outbound proxy refused the lookup and replies and forwards went out with a bare email address as the sender — recipients saw an address instead of a name. Connectors declare their allowlist in `build()`, and a composite does not inherit the allowlists of the connectors whose code it reuses. Adding the endpoint to the standalone Gmail connector therefore left the composite still blocked, with passing tests either side of the gap. The new test drives the composite's real `build()` and asserts the allowlist. Also make the fallback observable. It is deliberately best-effort so a failed lookup never blocks a send, but two paths degraded silently: - A 403 — the endpoint missing from the allowlist, or a grant without the `profile` scope — logged only a generic warning. It now logs an error naming both causes; a 403 is a misconfiguration that degrades every send, not a transient blip. - A 200 with no `name` claim, which is what a grant without the `profile` scope returns, threw nothing at all and silently produced a bare address. It now logs an error pointing at re-authorization. `getUserInfo` throws a typed `UserInfoError` carrying the HTTP status so callers can separate a misconfiguration from a transient failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ba7876c commit a7c3cad

5 files changed

Lines changed: 143 additions & 3 deletions

File tree

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,23 @@ export type SyncState = {
7878
historyFloor?: Date;
7979
};
8080

81+
/**
82+
* A non-OK response from Google's userinfo endpoint (used only to resolve the
83+
* account's display name for outbound `From` headers). Carries `status` so
84+
* callers can separate a misconfiguration — a 403 from the outbound proxy when
85+
* the endpoint is absent from the connector's Network allowlist, or from
86+
* Google when the grant lacks `profile` — from a transient failure.
87+
*/
88+
export class UserInfoError extends Error {
89+
constructor(
90+
public status: number,
91+
public statusText: string,
92+
) {
93+
super(`UserInfo error: ${status} ${statusText}`);
94+
this.name = "UserInfoError";
95+
}
96+
}
97+
8198
export class GmailApiError extends Error {
8299
constructor(
83100
public status: number,
@@ -394,9 +411,7 @@ export class GmailApi {
394411
{ headers: { Authorization: `Bearer ${this.accessToken}` } }
395412
);
396413
if (!response.ok) {
397-
throw new Error(
398-
`UserInfo error: ${response.status} ${response.statusText}`
399-
);
414+
throw new UserInfoError(response.status, response.statusText);
400415
}
401416
return response.json() as Promise<{ email: string; name?: string }>;
402417
}

‎connectors/gmail/src/sync.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { CreateLinkDraft, NewLinkWithNotes, Uuid } from "@plotday/twister";
55
import {
66
GmailApi,
77
GmailApiError,
8+
UserInfoError,
89
type GmailHeader,
910
type GmailMessage,
1011
type GmailMessagePart,
@@ -305,6 +306,59 @@ describe("onCreateLinkFn — draft.forward", () => {
305306
expect(raw).toContain("From: me@example.com");
306307
expect(raw).not.toContain('From: "');
307308
});
309+
310+
it("logs an error naming the allowlist when the userinfo lookup is blocked (403)", async () => {
311+
vi.spyOn(GmailApi.prototype, "getMessage").mockResolvedValue(sourceMessage());
312+
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
313+
emailAddress: "me@example.com",
314+
});
315+
vi.spyOn(GmailApi.prototype, "getUserInfo").mockRejectedValue(
316+
new UserInfoError(403, "Forbidden")
317+
);
318+
const sendNewMessage = vi
319+
.spyOn(GmailApi.prototype, "sendNewMessage")
320+
.mockResolvedValue({ id: "sent-5", threadId: "sent-thread-5" });
321+
const onError = vi.spyOn(console, "error").mockImplementation(() => {});
322+
const { host } = makeHost();
323+
324+
await onCreateLinkFn(host, forwardDraft());
325+
326+
// The send still goes out — the display name is best-effort.
327+
const raw = decodeRawMessage(sendNewMessage.mock.calls[0][0]);
328+
expect(raw).toContain("From: me@example.com");
329+
expect(raw).not.toContain('From: "');
330+
// A 403 is a misconfiguration, not a transient blip: say so loudly, and
331+
// name the two things that actually cause it.
332+
const logged = onError.mock.calls.map((c) => String(c[0])).join("\n");
333+
expect(logged).toMatch(/allowlist/i);
334+
expect(logged).toMatch(/profile/i);
335+
});
336+
337+
it("logs an error and sends bare when userinfo returns no display name", async () => {
338+
vi.spyOn(GmailApi.prototype, "getMessage").mockResolvedValue(sourceMessage());
339+
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
340+
emailAddress: "me@example.com",
341+
});
342+
// A grant without the `profile` scope still returns 200 here — just with
343+
// no `name` claim. This degrades silently unless it's called out.
344+
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
345+
email: "me@example.com",
346+
});
347+
const sendNewMessage = vi
348+
.spyOn(GmailApi.prototype, "sendNewMessage")
349+
.mockResolvedValue({ id: "sent-6", threadId: "sent-thread-6" });
350+
const onError = vi.spyOn(console, "error").mockImplementation(() => {});
351+
const { host } = makeHost();
352+
353+
await onCreateLinkFn(host, forwardDraft());
354+
355+
const raw = decodeRawMessage(sendNewMessage.mock.calls[0][0]);
356+
expect(raw).toContain("From: me@example.com");
357+
expect(raw).not.toContain('From: "');
358+
const logged = onError.mock.calls.map((c) => String(c[0])).join("\n");
359+
expect(logged).toMatch(/profile/i);
360+
expect(logged).toMatch(/re-auth/i);
361+
});
308362
});
309363

310364
function calThread(over: Record<string, unknown> = {}) {

‎connectors/gmail/src/sync.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { enrichLinkContactsFromGoogle } from "@plotday/connector-google-contacts
3939
import {
4040
GmailApi,
4141
GmailApiError,
42+
UserInfoError,
4243
type GmailMessage,
4344
type GmailThread,
4445
type AttachmentData,
@@ -1521,8 +1522,35 @@ async function saveTransformedThread(
15211522
async function getFromHeaderFn(api: GmailApi, email: string): Promise<string> {
15221523
try {
15231524
const userInfo = await api.getUserInfo();
1525+
if (!userInfo.name) {
1526+
// A grant without the `profile` scope still answers 200 here — just
1527+
// with no `name` claim. Nothing throws, so without this the send
1528+
// degrades to a bare address with no trace of why.
1529+
console.error(
1530+
"[gmail] userinfo returned no display name; this connection's grant " +
1531+
"is likely missing the 'profile' scope. Sending with a bare From " +
1532+
"address until the connection is re-authorized."
1533+
);
1534+
return email;
1535+
}
15241536
return formatFromHeader(email, userInfo.name);
15251537
} catch (error) {
1538+
if (error instanceof UserInfoError && error.status === 403) {
1539+
// A 403 is a misconfiguration, not a blip, and it degrades *every*
1540+
// send for the connection until fixed. Either the outbound proxy
1541+
// rejected the URL (missing from this connector's Network allowlist —
1542+
// note composite connectors declare their own) or Google refused the
1543+
// grant. Both are silent from the recipient's side, so log loudly.
1544+
console.error(
1545+
"[gmail] userinfo lookup refused (403): the endpoint may be missing " +
1546+
"from this connector's Network allowlist, or the grant may lack " +
1547+
"the 'profile' scope. Sending with a bare From address.",
1548+
error
1549+
);
1550+
return email;
1551+
}
1552+
// Anything else (transient network failure, 5xx, expired token) is
1553+
// genuinely best-effort: the send should still go out.
15261554
console.warn(
15271555
"[gmail] failed to fetch display name for From header:",
15281556
error

‎connectors/google/src/google.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ export class Google extends Connector<Google> {
125125
"https://gmail.googleapis.com/gmail/v1/*",
126126
"https://people.googleapis.com/v1/*",
127127
"https://tasks.googleapis.com/*",
128+
// The mail send path resolves the account's display name here to
129+
// build a `"Name" <email>` From header. Without this entry the
130+
// outbound proxy 403s the lookup and sends fall back to a bare
131+
// address.
132+
"https://www.googleapis.com/oauth2/v3/userinfo",
128133
],
129134
}),
130135
googleContacts: build(GoogleContacts),
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, it, expect } from "vitest";
2+
import type { ToolBuilder } from "@plotday/twister";
3+
import { Google } from "../src/google";
4+
5+
/**
6+
* The composite connector reuses the mail send path from
7+
* `@plotday/connector-gmail`, which resolves the account's display name from
8+
* Google's userinfo endpoint to build a `"Name" <email>` From header. That
9+
* lookup is a plain `fetch`, so the endpoint must be in this connector's own
10+
* Network allowlist — the outbound proxy 403s anything that isn't, and the
11+
* send path then silently falls back to a bare address.
12+
*/
13+
function captureNetworkUrls(): string[] | undefined {
14+
let urls: string[] | undefined;
15+
const builder = ((_tool: unknown, options?: { urls?: string[] }) => {
16+
if (options?.urls) urls = options.urls;
17+
return {} as never;
18+
}) as unknown as ToolBuilder;
19+
20+
Google.prototype.build.call({} as Google, builder);
21+
return urls;
22+
}
23+
24+
describe("Google composite — network allowlist", () => {
25+
it("permits the userinfo endpoint used to build the outbound From display name", () => {
26+
expect(captureNetworkUrls()).toContain(
27+
"https://www.googleapis.com/oauth2/v3/userinfo"
28+
);
29+
});
30+
31+
it("still permits the per-product API hosts", () => {
32+
const urls = captureNetworkUrls();
33+
expect(urls).toContain("https://gmail.googleapis.com/gmail/v1/*");
34+
expect(urls).toContain("https://www.googleapis.com/calendar/*");
35+
expect(urls).toContain("https://people.googleapis.com/v1/*");
36+
expect(urls).toContain("https://tasks.googleapis.com/*");
37+
});
38+
});

0 commit comments

Comments
 (0)