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
63 changes: 63 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import {
buildForwardMessage,
buildNewEmailMessage,
buildReplyMessage,
formatFromHeader,
stripQuotedReply,
transformGmailThread,
type AttachmentData,
Expand DownExpand Up@@ -128,6 +129,68 @@ describe("GmailApi.call empty-body responses", () => {
emailAddress: "kris@plot.day",
});
});

it("getUserInfo fetches the display name from Google's userinfo endpoint", async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({ email: "kris@plot.day", name: "Kris Braun" }),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
vi.stubGlobal("fetch", fetchMock);
const api = new GmailApi("test-token");

await expect(api.getUserInfo()).resolves.toEqual({
email: "kris@plot.day",
name: "Kris Braun",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://www.googleapis.com/oauth2/v3/userinfo",
{ headers: { Authorization: "Bearer test-token" } }
);
});

it("getUserInfo throws on a non-OK response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(null, { status: 401, statusText: "Unauthorized" })
)
);
const api = new GmailApi("test-token");

await expect(api.getUserInfo()).rejects.toThrow("UserInfo error: 401");
});
});

describe("formatFromHeader", () => {
it("quotes the display name around the email when a name is available", () => {
expect(formatFromHeader("kris@plot.day", "Kris Braun")).toBe(
'"Kris Braun" <kris@plot.day>'
);
});

it("falls back to a bare email when no name is available", () => {
expect(formatFromHeader("kris@plot.day")).toBe("kris@plot.day");
expect(formatFromHeader("kris@plot.day", null)).toBe("kris@plot.day");
expect(formatFromHeader("kris@plot.day", "")).toBe("kris@plot.day");
});

it("escapes quotes and backslashes in the name", () => {
expect(formatFromHeader("kris@plot.day", 'Kris "K" Braun')).toBe(
'"Kris \\"K\\" Braun" <kris@plot.day>'
);
expect(formatFromHeader("kris@plot.day", "Kris\\Braun")).toBe(
'"Kris\\\\Braun" <kris@plot.day>'
);
});

it("strips CRLF injection attempts from both name and email", () => {
expect(
formatFromHeader("kris@plot.day", "Kris\r\nBcc: evil@example.com")
).toBe('"Kris Bcc: evil@example.com" <kris@plot.day>');
});
});

describe("forwarded email body extraction", () => {
Expand Down
33 changes: 33 additions & 0 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -381,6 +381,25 @@ export class GmailApi {
return await this.call("/profile");
}

/**
* Fetches the authenticated user's profile via Google's userinfo endpoint
* to get their display name — Gmail's own `/profile` only returns the
* email address. Used to populate the `From` header with `"Name" <email>`
* instead of a bare address (which Gmail displays without a name).
*/
public async getUserInfo(): Promise<{ email: string; name?: string }> {
const response = await fetch(
"https://www.googleapis.com/oauth2/v3/userinfo",
{ headers: { Authorization: `Bearer ${this.accessToken}` } }
);
if (!response.ok) {
throw new Error(
`UserInfo error: ${response.status} ${response.statusText}`
);
}
return response.json() as Promise<{ email: string; name?: string }>;
}

/**
* Fetches a message attachment by its attachment ID.
* Returns the base64url-encoded attachment data.
Expand DownExpand Up@@ -1372,6 +1391,20 @@ function sanitizeHeaderValue(value: string): string {
return value.replace(/[\r\n\x00]+/g, " ").trim();
}

/**
* Formats a `From` header value as `"Display Name" <email>` per RFC 5322,
* falling back to a bare email address when no display name is available.
* The name is quoted with internal quotes/backslashes escaped since Google
* account names may contain commas or other special characters.
*/
export function formatFromHeader(email: string, name?: string | null): string {
const cleanEmail = sanitizeHeaderValue(email);
const cleanName = name ? sanitizeHeaderValue(name) : "";
if (!cleanName) return cleanEmail;
const escapedName = cleanName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `"${escapedName}" <${cleanEmail}>`;
}

/**
* Builds an RFC 2822 email message for a new (non-reply) email.
* Returns the base64url-encoded raw message string for the Gmail API.
Expand Down
1 change: 1 addition & 0 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ export class Gmail extends Connector<Gmail> {
urls: [
"https://gmail.googleapis.com/gmail/v1/*",
"https://people.googleapis.com/v1/*",
"https://www.googleapis.com/oauth2/v3/userinfo",
],
}),
files: build(Files),
Expand Down
39 changes: 39 additions & 0 deletions connectors/gmail/src/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,6 +119,10 @@ describe("onCreateLinkFn — draft.forward", () => {
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
emailAddress: "me@example.com",
});
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
email: "me@example.com",
name: "Me Myself",
});
const sendNewMessage = vi
.spyOn(GmailApi.prototype, "sendNewMessage")
.mockResolvedValue({ id: "sent-1", threadId: "sent-thread-1" });
Expand All@@ -132,6 +136,9 @@ describe("onCreateLinkFn — draft.forward", () => {
expect(raw).toContain("Subject: Fwd: Q3");
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("In-Reply-To:");
// From carries the account's display name, not a bare address, so Gmail
// shows the sender's name in the recipient's inbox.
expect(raw).toContain('From: "Me Myself" <me@example.com>');

const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("fyi"); // forwarder's own message
Expand All@@ -148,6 +155,10 @@ describe("onCreateLinkFn — draft.forward", () => {
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
emailAddress: "me@example.com",
});
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
email: "me@example.com",
name: "Me Myself",
});
const sendNewMessage = vi
.spyOn(GmailApi.prototype, "sendNewMessage")
.mockResolvedValue({ id: "sent-2", threadId: "sent-thread-2" });
Expand All@@ -164,6 +175,10 @@ describe("onCreateLinkFn — draft.forward", () => {
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
emailAddress: "me@example.com",
});
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
email: "me@example.com",
name: "Me Myself",
});
vi.spyOn(GmailApi.prototype, "sendNewMessage").mockRejectedValue(
new GmailApiError(400, "Bad Request", "Recipient address rejected")
);
Expand DownExpand Up@@ -223,6 +238,10 @@ describe("onCreateLinkFn — draft.forward", () => {
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
emailAddress: "me@example.com",
});
vi.spyOn(GmailApi.prototype, "getUserInfo").mockResolvedValue({
email: "me@example.com",
name: "Me Myself",
});
const sendNewMessage = vi
.spyOn(GmailApi.prototype, "sendNewMessage")
.mockResolvedValue({ id: "sent-3", threadId: "sent-thread-3" });
Expand DownExpand Up@@ -254,4 +273,24 @@ describe("onCreateLinkFn — draft.forward", () => {
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("To: bob@example.com, eve@example.com");
});

it("falls back to a bare email From header when the display-name lookup fails", async () => {
vi.spyOn(GmailApi.prototype, "getMessage").mockResolvedValue(sourceMessage());
vi.spyOn(GmailApi.prototype, "getProfile").mockResolvedValue({
emailAddress: "me@example.com",
});
vi.spyOn(GmailApi.prototype, "getUserInfo").mockRejectedValue(
new Error("UserInfo error: 401 Unauthorized")
);
const sendNewMessage = vi
.spyOn(GmailApi.prototype, "sendNewMessage")
.mockResolvedValue({ id: "sent-4", threadId: "sent-thread-4" });
const { host } = makeHost();

await onCreateLinkFn(host, forwardDraft());

const raw = decodeRawMessage(sendNewMessage.mock.calls[0][0]);
expect(raw).toContain("From: me@example.com");
expect(raw).not.toContain('From: "');
});
});
29 changes: 25 additions & 4 deletions connectors/gmail/src/sync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ import {
buildReplyMessage,
collectAttachments,
extractBody,
formatFromHeader,
getHeader,
isGmailRateLimitError,
mapWithConcurrency,
Expand DownExpand Up@@ -1492,6 +1493,27 @@ async function saveTransformedThread(
// Outbound: reply / read / star / compose
// ---------------------------------------------------------------------------

/**
* Builds the `From` header value for an outbound send: `"Display Name"
* <email>` when the account's Google profile has a name, otherwise a bare
* email address. The userinfo lookup is best-effort — Gmail's own
* `/profile` endpoint (used for `email`) has no name field, and if the
* separate userinfo call fails for any reason, sends still go out with the
* bare address rather than being blocked.
*/
async function getFromHeaderFn(api: GmailApi, email: string): Promise<string> {
try {
const userInfo = await api.getUserInfo();
return formatFromHeader(email, userInfo.name);
} catch (error) {
console.warn(
"[gmail] failed to fetch display name for From header:",
error
);
return email;
}
}

export async function onNoteCreatedFn(
host: GmailSyncHost,
note: Note,
Expand DownExpand Up@@ -1648,7 +1670,7 @@ export async function onNoteCreatedFn(
to,
cc,
bcc,
from: senderEmail,
from: await getFromHeaderFn(api, profile.emailAddress),
subject,
body: note.content ?? "",
messageId,
Expand DownExpand Up@@ -2100,7 +2122,7 @@ async function onCreateLinkForwardFn(
to,
cc,
bcc,
from: profile.emailAddress,
from: await getFromHeaderFn(api, profile.emailAddress),
subject,
body,
originalHeader,
Expand DownExpand Up@@ -2209,7 +2231,6 @@ export async function onCreateLinkFn(
}

const profile = await api.getProfile();
const fromEmail = profile.emailAddress;

const subject = draft.title || "";
const body = draft.noteContent ?? "";
Expand DownExpand Up@@ -2271,7 +2292,7 @@ export async function onCreateLinkFn(
to: toEmails,
cc: ccEmails,
bcc: bccEmails,
from: fromEmail,
from: await getFromHeaderFn(api, profile.emailAddress),
subject,
body,
});
Expand Down
Loading