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
2 changes: 1 addition & 1 deletion apps/cli/src/program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ test("config set rejects invalid gmail.syncFilter values", async () => {

expect(exitCode).toBe(EXIT_CODES.CONFIG_ERROR);
expect(errors).toContain(
"gmail.syncFilter must be one of: primary, primary-important.",
"gmail.syncFilter must be one of: primary, primary-important, inbox.",
);
});
});
Expand Down
117 changes: 116 additions & 1 deletion packages/connector-gmail/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ function createRequest(
_adapter: GmailAdapter,
options: {
since?: string | null;
syncFilter?: "primary" | "primary-important";
syncFilter?: "primary" | "primary-important" | "inbox";
secrets?: SecretsStore;
resolvedAuth?: GoogleResolvedAuth | null;
persistSource?: (source: SourceSnapshot) => Promise<void>;
Expand Down Expand Up @@ -970,3 +970,118 @@ test("html stripping handles common markup cleanup", () => {
"Hello\nworld",
);
});

test("inbox sync filter passes syncFilter=inbox to listInboxMessageIds", async () => {
let requestedFilter: string | undefined;
const adapter = createAdapter({
async listInboxMessageIds(_credentials, syncFilter): Promise<string[]> {
requestedFilter = syncFilter;
return ["m1"];
},
});
const connector = createGmailConnector({ adapter });
await connector.sync(createRequest(adapter, { syncFilter: "inbox" }));

expect(requestedFilter).toBe("inbox");
});

test("inbox sync filter persists messages with INBOX but without CATEGORY_PERSONAL", async () => {
const persisted: string[] = [];
const deleted: string[] = [];
const adapter = createAdapter({
async listInboxMessageIds(): Promise<string[]> {
return ["m1", "m2"];
},
async getMessage(_credentials, messageId): Promise<GmailMessage | null> {
return messageId === "m1"
? createMessage("m1", { labelIds: ["INBOX", "CATEGORY_PROMOTIONS"] })
: createMessage("m2", { labelIds: ["INBOX", "CATEGORY_PERSONAL"] });
},
});
const connector = createGmailConnector({ adapter });

await connector.sync(
createRequest(adapter, {
syncFilter: "inbox",
persistSource: async (source) => {
persisted.push(source.sourceId);
},
deleteSource: async (sourceId) => {
deleted.push(sourceId);
},
}),
);

expect(persisted).toEqual(["m1", "m2"]);
expect(deleted).toEqual([]);
});

test("inbox sync filter deletes messages without INBOX label", async () => {
const persisted: string[] = [];
const deleted: string[] = [];
const writes: string[] = [];
const adapter = createAdapter({
async listHistory(): Promise<GmailHistoryResult> {
return {
history: [{ labelsRemoved: [{ message: { id: "m1" } }] }],
};
},
async getMessage(): Promise<GmailMessage | null> {
return createMessage("m1", { labelIds: ["CATEGORY_PROMOTIONS"] });
},
});
const connector = createGmailConnector({ adapter });

await connector.sync(
createRequest(adapter, {
since: JSON.stringify({ historyId: "250", syncFilter: "inbox" }),
syncFilter: "inbox",
persistSource: async (source) => {
persisted.push(source.sourceId);
},
deleteSource: async (sourceId) => {
deleted.push(sourceId);
},
io: {
write(line) {
writes.push(line);
},
error() {},
},
}),
);

expect(persisted).toEqual([]);
expect(deleted).toEqual(["m1"]);
expect(writes).toContain(
"Gmail message removed from the active inbox filter during sync: m1",
);
});

test("inbox cursor is accepted as valid and not treated as legacy", async () => {
let resetCalls = 0;
let inboxCalls = 0;
const adapter = createAdapter({
async listHistory(): Promise<GmailHistoryResult> {
return { history: [] };
},
async listInboxMessageIds(): Promise<string[]> {
inboxCalls += 1;
return [];
},
});
const connector = createGmailConnector({ adapter });

await connector.sync(
createRequest(adapter, {
since: JSON.stringify({ historyId: "250", syncFilter: "inbox" }),
syncFilter: "inbox",
resetIntegrationState: async () => {
resetCalls += 1;
},
}),
);

expect(resetCalls).toBe(0);
expect(inboxCalls).toBe(0);
});
36 changes: 22 additions & 14 deletions packages/connector-gmail/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,15 +557,16 @@ function getGmailSyncFilter(request: ConnectorSyncRequest): GmailSyncFilter {
);
}

return request.integration.config.syncFilter === "primary-important"
? "primary-important"
: "primary";
const filter = request.integration.config.syncFilter;
if (filter === "primary-important") return "primary-important";
if (filter === "inbox") return "inbox";
return "primary";
}

function toSearchQuery(syncFilter: GmailSyncFilter): string {
return syncFilter === "primary-important"
? "category:primary label:important"
: "category:primary";
if (syncFilter === "primary-important") return "category:primary label:important";
if (syncFilter === "inbox") return "in:inbox";
return "category:primary";
}

function hasLabel(message: GmailMessage, label: string): boolean {
Expand All @@ -576,7 +577,11 @@ function isMessageEligible(
message: GmailMessage,
syncFilter: GmailSyncFilter,
): boolean {
if (!hasLabel(message, "INBOX") || !hasLabel(message, "CATEGORY_PERSONAL")) {
if (!hasLabel(message, "INBOX")) {
return false;
}

if (syncFilter !== "inbox" && !hasLabel(message, "CATEGORY_PERSONAL")) {
return false;
}

Expand All @@ -588,9 +593,9 @@ function isMessageEligible(
}

function formatRemovalReason(syncFilter: GmailSyncFilter): string {
return syncFilter === "primary-important"
? "Gmail message removed from the active primary+important filter during sync"
: "Gmail message removed from the active primary filter during sync";
if (syncFilter === "primary-important") return "Gmail message removed from the active primary+important filter during sync";
if (syncFilter === "inbox") return "Gmail message removed from the active inbox filter during sync";
return "Gmail message removed from the active primary filter during sync";
}

function encodeCursor(
Expand Down Expand Up @@ -623,7 +628,8 @@ function decodeCursor(

if (
parsed.syncFilter !== "primary" &&
parsed.syncFilter !== "primary-important"
parsed.syncFilter !== "primary-important" &&
parsed.syncFilter !== "inbox"
) {
return { historyId: null, resetReason: "legacy" };
}
Expand Down Expand Up @@ -1073,7 +1079,9 @@ function normalizeGmailIntegration(entry: Partial<IntegrationConfig>) {
const syncFilter: GmailSyncFilter =
config?.syncFilter === "primary-important"
? "primary-important"
: "primary";
: config?.syncFilter === "inbox"
? "inbox"
: "primary";
return [
{
id: entry.id,
Expand Down Expand Up @@ -1183,9 +1191,9 @@ export function createGmailConnectorPlugin(
{
key: "gmail.syncFilter",
async setValue(context, rawValue) {
if (rawValue !== "primary" && rawValue !== "primary-important") {
if (rawValue !== "primary" && rawValue !== "primary-important" && rawValue !== "inbox") {
throw new Error(
"gmail.syncFilter must be one of: primary, primary-important.",
"gmail.syncFilter must be one of: primary, primary-important, inbox.",
);
}
const integration = context.config.integrations.find(
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export type ConnectionConfig =
| NotionOAuthConnectionConfig
| AppleNotesLocalConnectionConfig;

export type GmailSyncFilter = "primary" | "primary-important";
export type GmailSyncFilter = "primary" | "primary-important" | "inbox";

export interface GmailIntegrationSettings {
fetchConcurrency?: number;
Expand Down
Loading