sh-mcp/packages/gmail/src/client.ts
Adam Moussa 0d1fefb326
Some checks are pending
deploy / deploy (push) Waiting to run
Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2)
* Phase 0b slice: monorepo scaffold + shared core + integration packages

The 0a-INDEPENDENT code slice (one-shot via af-0b-package-slice workflow: Haiku
scaffold + Sonnet packages, Sonnet fix-to-green). Nothing deploys; no CDK/servers.

- Monorepo scaffold: npm workspaces, strict TS (NodeNext), vitest (80% gate),
  eslint 9 flat config, prettier; ci.yaml/deploy.yaml callers (Node 24, enable-qemu).
- @sh-mcp/shared: transport-agnostic core — Scope/AuthContext/ToolDef, ToolRegistry,
  redact()+maskValue() (PII), OpenAPI 3.1 generator. AUTH STUBBED behind an AuthProvider
  interface (TODO auth-layer-0a); JWT/aud/client_id/JWKS/deny-list deferred per design.md §2.
- 9 integration packages (qbo, google-maps, internal-data, payments, knowledge-base,
  gmail, calendar, tasks, reminders): tools against shared, external deps mocked behind
  injected client interfaces; finance handlers call redact().

Verified green: tsc -b clean, vitest 245/245, eslint 0 errors. Auth mechanism intentionally
deferred until the 0a spike resolves it (G16/§0.4).

* Complete Cognito auth provider + Phase 1 build brief

Finish the WIP CognitoAuthProvider (client_id allow-list as audience
boundary, finance TTL ceiling, deny-list, scope-prefix stripping) with
its test suite, and check in docs/build-plan-phase-1.md so the Phase 1
work has its governing brief in-tree (design.md §2.5).

* ci: disable cdk synth for Phase 0b (no CDK app yet)

The reusable ci-typescript-cdk workflow defaults run-cdk-synth: true, but
the Phase 0b package scaffold has no cdk.json or stacks, so cdk synth fails
with '--app is required'. Disable it here; Phase 1 re-enables it with the
server CDK stubs.
2026-06-26 12:42:17 -04:00

139 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Gmail client interface + thin implementation.
*
* The real Google API call is clearly stubbed/guarded behind the interface so
* that nothing in this module imports the Google client SDK at module load time.
* All consumers (tools.ts and tests) inject a GmailClient — no live network
* traffic ever occurs during import or unit tests.
*
* Per §2.4 of the design:
* - This module acts AS the signed-in user via a per-user Google OAuth token.
* - The GoogleTokenProvider is injected; the implementation fetches a
* short-lived access token on demand and never caches it.
* - The refresh token is NEVER returned or logged.
* - The client is instantiated with the minimal scope for the requested tool
* only (gmail.readonly) — never the union of a user's scopes.
*/
export interface EmailMessage {
id: string;
threadId: string;
subject: string;
from: string;
to: string;
date: string;
/** Plain-text snippet — safe for log output. */
snippet: string;
}
export interface EmailThread {
threadId: string;
subject: string;
messages: Array<{
id: string;
from: string;
to: string;
date: string;
/** Full decoded plain-text body. */
body: string;
}>;
}
export interface SearchInboxParams {
/** Gmail query string, e.g. "from:vendor@example.com subject:invoice" */
query: string;
/** Maximum number of results to return (1–50). */
maxResults: number;
/** Sub of the calling user — used to look up the per-user Google token. */
userSub: string;
}
export interface GetThreadDetailParams {
threadId: string;
userSub: string;
}
/**
* The interface all callers (tools + tests) program against.
* Tests inject a mock; production code injects GmailApiClient.
*/
export interface GmailClient {
searchInbox(params: SearchInboxParams): Promise<EmailMessage[]>;
getThreadDetail(params: GetThreadDetailParams): Promise<EmailThread>;
}
/**
* Provides a short-lived Google access token for a given user sub.
* The real implementation reads the encrypted refresh token from DynamoDB
* (KMS-CMK, ABAC-partitioned by sub) and exchanges it for an access token.
* Tests inject a mock that returns a hard-coded dummy token.
*
* IMPORTANT: The refresh token MUST NOT be returned or surfaced anywhere
* outside this provider. Access tokens are minted per request; do not cache.
*/
export interface GoogleTokenProvider {
getAccessToken(userSub: string): Promise<string>;
}
/**
* Production Gmail client.
*
* The actual HTTP call to the Gmail REST API is guarded behind the
* STUB comment below. To complete the real implementation:
* 1. npm install googleapis (or use undici/fetch directly)
* 2. Build a google.auth.OAuth2 client from the access token returned
* by tokenProvider.getAccessToken()
* 3. Call gmail.users.messages.list / gmail.users.threads.get
*
* The stub throws so that accidental real calls fail loudly in tests.
*/
export class GmailApiClient implements GmailClient {
// Stored for use by the real implementation once the Gmail SDK call is wired.
private readonly _tokenProvider: GoogleTokenProvider;
constructor(tokenProvider: GoogleTokenProvider) {
this._tokenProvider = tokenProvider;
// Mark as intentionally stored-but-unused until the real SDK call is wired.
void this._tokenProvider;
}
async searchInbox(params: SearchInboxParams): Promise<EmailMessage[]> {
// TODO: Replace this stub with the real Gmail API call.
// When implementing, fetch a short-lived access token first:
// const accessToken = await this.tokenProvider.getAccessToken(params.userSub);
// Example (googleapis):
// const auth = new google.auth.OAuth2();
// auth.setCredentials({ access_token: accessToken });
// const gmail = google.gmail({ version: 'v1', auth });
// const res = await gmail.users.messages.list({
// userId: 'me',
// q: params.query,
// maxResults: params.maxResults,
// });
// return parseMessageList(res.data);
void params; // suppress unused-param warning; remove when real call is wired
throw new Error(
'GmailApiClient.searchInbox is not implemented — inject a GmailClient mock in tests and wire the real SDK call here for production.',
);
}
async getThreadDetail(params: GetThreadDetailParams): Promise<EmailThread> {
// TODO: Replace this stub with the real Gmail API call.
// When implementing, fetch a short-lived access token first:
// const accessToken = await this.tokenProvider.getAccessToken(params.userSub);
// Example (googleapis):
// const auth = new google.auth.OAuth2();
// auth.setCredentials({ access_token: accessToken });
// const gmail = google.gmail({ version: 'v1', auth });
// const res = await gmail.users.threads.get({
// userId: 'me',
// id: params.threadId,
// format: 'full',
// });
// return parseThread(res.data);
void params; // suppress unused-param warning; remove when real call is wired
throw new Error(
'GmailApiClient.getThreadDetail is not implemented — inject a GmailClient mock in tests and wire the real SDK call here for production.',
);
}
}