diff --git a/apps/cli/src/program.test.ts b/apps/cli/src/program.test.ts index af5a162..7a92bfe 100644 --- a/apps/cli/src/program.test.ts +++ b/apps/cli/src/program.test.ts @@ -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.", ); }); }); diff --git a/packages/connector-gmail/src/index.test.ts b/packages/connector-gmail/src/index.test.ts index 4a486ff..da8d508 100644 --- a/packages/connector-gmail/src/index.test.ts +++ b/packages/connector-gmail/src/index.test.ts @@ -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; @@ -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 { + 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 { + return ["m1", "m2"]; + }, + async getMessage(_credentials, messageId): Promise { + 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 { + return { + history: [{ labelsRemoved: [{ message: { id: "m1" } }] }], + }; + }, + async getMessage(): Promise { + 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 { + return { history: [] }; + }, + async listInboxMessageIds(): Promise { + 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); +}); diff --git a/packages/connector-gmail/src/index.ts b/packages/connector-gmail/src/index.ts index 6363340..00da9a6 100644 --- a/packages/connector-gmail/src/index.ts +++ b/packages/connector-gmail/src/index.ts @@ -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 { @@ -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; } @@ -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( @@ -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" }; } @@ -1073,7 +1079,9 @@ function normalizeGmailIntegration(entry: Partial) { const syncFilter: GmailSyncFilter = config?.syncFilter === "primary-important" ? "primary-important" - : "primary"; + : config?.syncFilter === "inbox" + ? "inbox" + : "primary"; return [ { id: entry.id, @@ -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( diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e4d7734..36ec413 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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;