Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.env.test.local
.env.production.local
.env.local
.local/

# caches
.eslintcache
Expand Down
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,48 @@
Corbits Tools for Google
# @corbits/google-tools

Google API clients and Interchange tools, starting with Gmail. Authentication
is supplied through the Interchange `gmail-api` mediated credential; the
package never accepts or returns raw tokens.

## Install

```bash
bun add github:corbitsdev/google-tools
```

## Working on it

```bash
bun install
bun run typecheck
bun test
bun run build
```

The tests inject `fetch` implementations and do not call Gmail.

## Live Gmail checks

Use a dedicated Gmail fixture account and a Desktop OAuth client granted only
`gmail.modify`. The read smoke requires a query that matches exactly one
one-message thread:

```bash
GMAIL_LIVE_TEST=1 \
GMAIL_LIVE_CLIENT_ID='...' \
GMAIL_LIVE_CLIENT_SECRET='...' \
GMAIL_LIVE_FIXTURE_QUERY='subject:(interchange-gmail-e2e-fixture)' \
bun run test:live
```

Add `GMAIL_LIVE_MUTATION_TEST=1` to also test draft creation and label changes.
The mutation suite deletes its draft and restores the fixture labels. It never
sends email.

The first run opens Google OAuth and stores the refresh token in the ignored
`.local/gmail-live-token.json` file. Set `GMAIL_LIVE_TOKEN_FILE` when using a
different token file. Credentials and message contents are not logged.

## License

LGPL-2.1-only. See [LICENSE](./LICENSE).
56 changes: 50 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion index.ts

This file was deleted.

72 changes: 67 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
"version": "0.1.0",
"type": "module",
"license": "LGPL-2.1-only",
"description": "Corbits Tools for Google",
"description": "Corbits Google tools for Interchange, starting with Gmail.",
"keywords": [
"corbits",
"google",
"gmail",
"tools",
"interchange"
],
"author": "Corbits",
"homepage": "https://github.com/corbitsdev/google-tools#readme",
"repository": {
Expand All @@ -13,11 +20,66 @@
"bugs": {
"url": "https://github.com/corbitsdev/google-tools/issues"
},
"module": "index.ts",
"devDependencies": {
"@types/bun": "latest"
"engines": {
"node": ">=22"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./sidecar-bundle": {
"bun": "./src/sidecar-bundle.ts",
"types": "./dist/sidecar-bundle.d.ts",
"default": "./dist/sidecar-bundle.js"
}
},
"interchange": {
"tools": "./dist/sidecar-bundle.js",
"credentials": [
{
"handle": "gmail-api",
"scopes": [
"https://www.googleapis.com/auth/gmail.modify"
]
}
]
},
"files": [
"dist",
"src",
"!src/**/*.test.ts",
"LICENSE",
"README.md"
],
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
"typecheck:live": "tsc -p tsconfig.live.json --noEmit",
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"prepack": "bun run build",
"test": "bun test src tests/live/credential.test.ts tests/live/fixtures.test.ts tests/live/oauth.test.ts",
"test:live": "bun test tests/live"
},
"peerDependencies": {
"typescript": "^5"
"@intx/agent": "*",
"@intx/types": "*"
},
"peerDependenciesMeta": {
"@intx/agent": { "optional": true },
"@intx/types": { "optional": true }
},
"devDependencies": {
"@intx/agent": "^0.3.0",
"@intx/types": "^0.3.0",
"@types/bun": "1.3.14",
"@types/node": "22.10.5",
"typescript": "5.9.3"
},
"dependencies": {
"arktype": "^2.2.3"
}
}
125 changes: 125 additions & 0 deletions src/client/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test";

import {
createGmailClient,
GmailApiError,
GMAIL_API_BASE_URL,
type GmailFetch,
} from "./client.js";

describe("createGmailClient", () => {
test("lists threads through the Gmail API without adding authorization", async () => {
const fetchImpl: GmailFetch = async (input, init) => {
const url = new URL(String(input));
expect(url.origin + url.pathname).toBe(`${GMAIL_API_BASE_URL}/threads`);
expect(url.searchParams.get("q")).toBe("from:ada@example.com");
expect(url.searchParams.get("maxResults")).toBe("10");
expect(url.searchParams.get("pageToken")).toBe("next-1");
expect(url.searchParams.get("includeSpamTrash")).toBe("true");
expect(new Headers(init?.headers).get("Authorization")).toBeNull();
expect(new Headers(init?.headers).get("User-Agent")).toBeNull();
return new Response(
JSON.stringify({
threads: [{ id: "thread-1", futureThreadField: true }],
futureEnvelopeField: { accepted: true },
}),
{ status: 200 },
);
};

const client = createGmailClient({ fetchImpl });
await expect(
client.listThreads({
query: "from:ada@example.com",
pageSize: 10,
pageToken: "next-1",
includeTrash: true,
}),
).resolves.toMatchObject({ threads: [{ id: "thread-1" }] });
});

test("encodes message IDs and selects the requested format", async () => {
const fetchImpl: GmailFetch = async (input) => {
const url = new URL(String(input));
expect(url.origin + url.pathname).toBe(
`${GMAIL_API_BASE_URL}/messages/message%2F1`,
);
expect(url.searchParams.get("format")).toBe("metadata");
return new Response(JSON.stringify({ id: "message/1" }), { status: 200 });
};

const client = createGmailClient({ fetchImpl });
await expect(
client.getMessage("message/1", { format: "metadata", metadataHeaders: [] }),
).resolves.toEqual({ id: "message/1" });
});

test("rejects malformed successful Gmail responses", async () => {
const fetchImpl: GmailFetch = async () =>
new Response(JSON.stringify({ messages: [{ threadId: "thread-1" }] }), {
status: 200,
});
const client = createGmailClient({ fetchImpl });

await expect(
client.listMessages({ query: "in:inbox", pageSize: 10 }),
).rejects.toThrow("Gmail API response did not match the expected list-messages shape");
});

test("rejects malformed Gmail headers", async () => {
const fetchImpl: GmailFetch = async () =>
new Response(
JSON.stringify({
id: "message-1",
payload: { headers: [{ name: "Subject", value: 42 }] },
}),
{ status: 200 },
);
const client = createGmailClient({ fetchImpl });

await expect(
client.getMessage("message-1", { format: "full" }),
).rejects.toThrow("Gmail API response did not match the expected get-message shape");
});

test("rejects an empty successful response body", async () => {
const fetchImpl: GmailFetch = async () => new Response("", { status: 200 });
const client = createGmailClient({ fetchImpl });

await expect(client.listLabels()).rejects.toThrow("response body was empty");
});

test("maps non-success responses to GmailApiError", async () => {
const fetchImpl: GmailFetch = async () =>
new Response('{"error":{"message":"forbidden"}}', {
status: 403,
statusText: "Forbidden",
});
const client = createGmailClient({ fetchImpl });

try {
await client.listLabels();
throw new Error("Expected listLabels to throw");
} catch (error) {
expect(error).toBeInstanceOf(GmailApiError);
if (!(error instanceof GmailApiError)) throw error;
expect(error.status).toBe(403);
expect(error.body).toContain("forbidden");
}
});

test("preserves the complete Gmail error response body", async () => {
const body = "x".repeat(2_001);
const fetchImpl: GmailFetch = async () =>
new Response(body, { status: 400, statusText: "Bad Request" });
const client = createGmailClient({ fetchImpl });

try {
await client.listLabels();
throw new Error("Expected listLabels to throw");
} catch (error) {
if (!(error instanceof GmailApiError)) throw error;
expect(error.body).toBe(body);
}
});
});
Loading