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
439 changes: 439 additions & 0 deletions src/__tests__/gmail/drafts.test.ts

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/__tests__/sdk/runtime-rate-limiter-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ describe('createSDKRuntime rate limiter injection', () => {
createSDKRuntime(context, limiter);
createSDKRuntime(context, limiter);

// 65 wrapped SDK operations per runtime creation (47 original + 12 Gmail v3.2 ops + 6 outreach ops).
// 69 wrapped SDK operations per runtime creation (47 original + 12 Gmail v3.2 ops + 6 outreach ops + 4 draft ops).
// Outreach P0: readAsRecords, sendFromTemplate, sendBatch = 3 (dryRun is unwrapped — pure function).
// Outreach P1: updateRecords, detectReplies, getTrackingData = 3.
expect(wrap).toHaveBeenCalledTimes(130);
// Draft management: listDrafts, getDraft, updateDraft, deleteDraft = 4.
expect(wrap).toHaveBeenCalledTimes(138);
});
});
297 changes: 297 additions & 0 deletions src/modules/gmail/drafts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
/**
* Gmail draft management operations
* listDrafts, getDraft, updateDraft, deleteDraft
*/

import type { gmail_v1 } from 'googleapis';
import type { GmailContext } from '../types.js';
import type {
ListDraftsOptions,
ListDraftsResult,
GetDraftOptions,
GetDraftResult,
UpdateDraftOptions,
UpdateDraftResult,
DeleteDraftOptions,
DeleteDraftResult,
} from './types.js';
import { buildEmailMessage, encodeToBase64Url } from './utils.js';

/**
* List all drafts in the user's mailbox.
*
* Returns draft IDs, subjects, and snippets for quick inspection.
* Use getDraft() to retrieve the full content of a specific draft.
*
* @param options Listing options (maxResults, pageToken)
* @param context Gmail API context
* @returns Paginated list of draft summaries
*
* @example
* ```typescript
* const result = await listDrafts({ maxResults: 10 }, context);
* result.drafts.forEach(d => console.log(d.draftId, d.subject));
* ```
*/
export async function listDrafts(
options: ListDraftsOptions,
context: GmailContext
): Promise<ListDraftsResult> {
const { maxResults = 10, pageToken } = options;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Default listDrafts options when args are omitted

listDrafts destructures options immediately, but the SDK spec added in this commit documents listDrafts(options?) and code-mode callers can invoke sdk.gmail.listDrafts() with no argument. In that case options is undefined and this throws before reaching Gmail (Cannot destructure property 'maxResults' ...). Please default the parameter (or wrapper input) to {} so the zero-arg call path works as documented.

Useful? React with 👍 / 👎.


// Build params — exactOptionalPropertyTypes requires we omit undefined keys
const listParams: gmail_v1.Params$Resource$Users$Drafts$List = {
userId: 'me',
maxResults: Math.min(maxResults, 500),
};
if (pageToken) {
listParams.pageToken = pageToken;
}

const listResponse = await context.gmail.users.drafts.list(listParams);

const drafts = listResponse.data.drafts || [];
const nextPageToken = listResponse.data.nextPageToken ?? undefined;
const resultSizeEstimate = listResponse.data.resultSizeEstimate ?? 0;

// Fetch subject/snippet for each draft in parallel (using metadata format for efficiency)
const draftSummaries = await Promise.all(
drafts.map(async (draftRef: gmail_v1.Schema$Draft) => {
if (!draftRef.id) {
return null;
}
try {
const draftResponse = await context.gmail.users.drafts.get({
userId: 'me',
id: draftRef.id,
format: 'metadata',
});

const headers = draftResponse.data.message?.payload?.headers || [];
const subject =
headers.find((h) => h.name?.toLowerCase() === 'subject')?.value ?? '(no subject)';
const to =
headers.find((h) => h.name?.toLowerCase() === 'to')?.value ?? '';
const snippet = draftResponse.data.message?.snippet ?? '';

return {
draftId: draftRef.id,
messageId: draftResponse.data.message?.id ?? '',
subject,
to,
snippet,
};
} catch {
// If we cannot fetch the individual draft, return minimal info
return {
draftId: draftRef.id,
messageId: '',
subject: '(unavailable)',
to: '',
snippet: '',
};
}
})
);
Comment on lines +57 to +95
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cap per-draft fetch concurrency to prevent quota spikes.

This currently launches up to 500 drafts.get requests at once. That burst pattern can trigger throttling and unstable partial output under load.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/gmail/drafts.ts` around lines 57 - 95, The current Promise.all
over drafts (draftSummaries created from drafts.map calling
context.gmail.users.drafts.get) can fire hundreds of concurrent requests; limit
concurrency by replacing the direct Promise.all(drafts.map(...)) pattern with a
bounded concurrency approach (e.g., use p-limit with a CONCURRENCY constant or
implement a small promise pool/batching loop) and run at most N parallel calls
to context.gmail.users.drafts.get (suggest N=5–20); preserve the same per-draft
error handling and returned object shape (draftId, messageId, subject, to,
snippet) inside the worker function used by the limiter so behavior is unchanged
except for throttling.


const filteredDrafts = draftSummaries.filter(Boolean) as ListDraftsResult['drafts'];

context.performanceMonitor.track('gmail:listDrafts', Date.now() - context.startTime);
context.logger.info('Listed drafts', { count: filteredDrafts.length });

return {
drafts: filteredDrafts,
nextPageToken,
resultSizeEstimate,
};
}

/**
* Get the full content of a specific draft by ID.
*
* Returns parsed headers and decoded body (to, cc, bcc, subject, body).
*
* @param options Draft ID to retrieve
* @param context Gmail API context
* @returns Full draft content
*
* @example
* ```typescript
* const draft = await getDraft({ draftId: 'r1234567890' }, context);
* console.log(draft.subject, draft.body);
* ```
*/
export async function getDraft(
options: GetDraftOptions,
context: GmailContext
): Promise<GetDraftResult> {
const { draftId } = options;

const response = await context.gmail.users.drafts.get({
userId: 'me',
id: draftId,
format: 'full',
});

const data = response.data;
const messageId = data.message?.id ?? '';
const threadId = data.message?.threadId ?? '';
const headers = data.message?.payload?.headers || [];

const getHeader = (name: string): string =>
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';

const subject = getHeader('Subject') || '(no subject)';
const to = getHeader('To');
const from = getHeader('From');
const cc = getHeader('Cc');
const bcc = getHeader('Bcc');
const date = getHeader('Date');

// Decode the message body
let body = '';
let isHtml = false;
const payload = data.message?.payload;
if (payload) {
if (payload.mimeType === 'text/plain' && payload.body?.data) {
body = Buffer.from(payload.body.data, 'base64').toString('utf-8');
} else if (payload.mimeType === 'text/html' && payload.body?.data) {
body = Buffer.from(payload.body.data, 'base64').toString('utf-8');
isHtml = true;
} else if (payload.parts) {
// Prefer plain text, fall back to HTML
const textPart = payload.parts.find((p) => p.mimeType === 'text/plain');
const htmlPart = payload.parts.find((p) => p.mimeType === 'text/html');
Comment on lines +161 to +164
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Traverse nested MIME parts when reading draft body

This branch only checks top-level payload.parts for text/plain/text/html. Drafts that include attachments are commonly multipart/mixed with the text body nested inside multipart/alternative, so getDraft returns an empty body even though content exists. Recursively scanning nested parts (similar to read.ts message parsing) is needed to reliably return full draft content.

Useful? React with 👍 / 👎.


if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString('utf-8');
} else if (htmlPart?.body?.data) {
body = Buffer.from(htmlPart.body.data, 'base64').toString('utf-8');
isHtml = true;
}
}
}
Comment on lines +151 to +173
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the actual file to confirm the code exists
cat -n src/modules/gmail/drafts.ts | head -200

Repository: AojdevStudio/gdrive

Length of output: 7365


🏁 Script executed:

# Look for the Gmail API type definitions to understand the schema
fd -e ts src/modules/gmail | head -20

Repository: AojdevStudio/gdrive

Length of output: 436


🌐 Web query:

Gmail API v1 message payload structure nested multipart handling

💡 Result:

The Gmail API v1 Message resource contains a payload field of type MessagePart, which represents the parsed MIME structure of the email. For nested multipart handling: - The top-level payload is a MessagePart object. - MessagePart fields: - mimeType: e.g., "multipart/mixed", "multipart/alternative", "text/plain". - partId: Unique ID for the part (empty for root). - headers: Array of Header objects (name/value pairs). - body: MessagePartBody with data (base64url-encoded content), size, or attachmentId. - parts: Array of child MessagePart objects (only for container types like multipart/*; empty for leaf types like text/plain). To handle nested multipart: 1. Start with message.payload. 2. If mimeType starts with "multipart/", recurse into parts[] array. 3. Leaf parts (no parts) contain content in body.data (decode base64url). 4. Use users.messages.get with format=full to get parsed payload (default behavior). Example structure for multipart/mixed with alternative text/html: { "payload": { "mimeType": "multipart/mixed", "parts": [ { "mimeType": "multipart/alternative", "parts": [ {"partId": "0.0", "mimeType": "text/plain", "body": {"data": "base64url_text"}}, {"partId": "0.1", "mimeType": "text/html", "body": {"data": "base64url_html"}} ] }, {"partId": "1", "mimeType": "image/png", "body": {"attachmentId": "A"}} ] } } Traverse recursively by partId and mimeType to extract bodies or fetch attachments via users.messages.attachments.get using attachmentId. To decode body.data: Replace '-' with '+', '_' with '/', pad with '=', then base64 decode.

Citations:


Add recursive MIME part traversal to handle nested multipart structures.

The current parser only checks first-level payload.parts and cannot retrieve message bodies from nested multipart structures (e.g., text/plain nested within multipart/alternative inside multipart/mixed). This is a common structure in Gmail API responses. Implement recursive traversal to find text content at any nesting depth.

Proposed fix (recursive MIME part lookup)
+  const findPartByMime = (
+    part: gmail_v1.Schema$MessagePart | undefined,
+    mimeType: 'text/plain' | 'text/html'
+  ): gmail_v1.Schema$MessagePart | undefined => {
+    if (!part) return undefined;
+    if (part.mimeType === mimeType && part.body?.data) return part;
+    for (const child of part.parts ?? []) {
+      const found = findPartByMime(child, mimeType);
+      if (found) return found;
+    }
+    return undefined;
+  };
+
   // Decode the message body
   let body = '';
   let isHtml = false;
   const payload = data.message?.payload;
   if (payload) {
-    if (payload.mimeType === 'text/plain' && payload.body?.data) {
-      body = Buffer.from(payload.body.data, 'base64').toString('utf-8');
-    } else if (payload.mimeType === 'text/html' && payload.body?.data) {
-      body = Buffer.from(payload.body.data, 'base64').toString('utf-8');
-      isHtml = true;
-    } else if (payload.parts) {
-      // Prefer plain text, fall back to HTML
-      const textPart = payload.parts.find((p) => p.mimeType === 'text/plain');
-      const htmlPart = payload.parts.find((p) => p.mimeType === 'text/html');
-
-      if (textPart?.body?.data) {
-        body = Buffer.from(textPart.body.data, 'base64').toString('utf-8');
-      } else if (htmlPart?.body?.data) {
-        body = Buffer.from(htmlPart.body.data, 'base64').toString('utf-8');
-        isHtml = true;
-      }
-    }
+    const textPart = findPartByMime(payload, 'text/plain');
+    const htmlPart = findPartByMime(payload, 'text/html');
+    if (textPart?.body?.data) {
+      body = Buffer.from(textPart.body.data, 'base64').toString('utf-8');
+    } else if (htmlPart?.body?.data) {
+      body = Buffer.from(htmlPart.body.data, 'base64').toString('utf-8');
+      isHtml = true;
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Decode the message body
let body = '';
let isHtml = false;
const payload = data.message?.payload;
if (payload) {
if (payload.mimeType === 'text/plain' && payload.body?.data) {
body = Buffer.from(payload.body.data, 'base64').toString('utf-8');
} else if (payload.mimeType === 'text/html' && payload.body?.data) {
body = Buffer.from(payload.body.data, 'base64').toString('utf-8');
isHtml = true;
} else if (payload.parts) {
// Prefer plain text, fall back to HTML
const textPart = payload.parts.find((p) => p.mimeType === 'text/plain');
const htmlPart = payload.parts.find((p) => p.mimeType === 'text/html');
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString('utf-8');
} else if (htmlPart?.body?.data) {
body = Buffer.from(htmlPart.body.data, 'base64').toString('utf-8');
isHtml = true;
}
}
}
const findPartByMime = (
part: gmail_v1.Schema$MessagePart | undefined,
mimeType: 'text/plain' | 'text/html'
): gmail_v1.Schema$MessagePart | undefined => {
if (!part) return undefined;
if (part.mimeType === mimeType && part.body?.data) return part;
for (const child of part.parts ?? []) {
const found = findPartByMime(child, mimeType);
if (found) return found;
}
return undefined;
};
// Decode the message body
let body = '';
let isHtml = false;
const payload = data.message?.payload;
if (payload) {
const textPart = findPartByMime(payload, 'text/plain');
const htmlPart = findPartByMime(payload, 'text/html');
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString('utf-8');
} else if (htmlPart?.body?.data) {
body = Buffer.from(htmlPart.body.data, 'base64').toString('utf-8');
isHtml = true;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/gmail/drafts.ts` around lines 151 - 173, The MIME parsing only
checks payload and first-level payload.parts so nested multipart structures are
missed; update the logic in the drafts parsing function (where payload,
payload.parts, body and isHtml are handled) to perform a recursive traversal of
parts: write a helper (e.g., findPartRecursive or getBodyFromParts) that walks
parts at any depth, looks for a text/plain part first and returns its decoded
body, otherwise falls back to text/html and sets isHtml=true; use that helper to
set body/isHtml instead of directly accessing payload.parts, and keep existing
base64 decoding behavior for part.body.data.


context.performanceMonitor.track('gmail:getDraft', Date.now() - context.startTime);
context.logger.info('Retrieved draft', { draftId, subject });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid logging draft subjects at info level.

subject can contain sensitive user content. Logging it at info level creates avoidable privacy/compliance exposure.

🔒 Proposed fix (log identifiers only)
-  context.logger.info('Retrieved draft', { draftId, subject });
+  context.logger.info('Retrieved draft', { draftId });
...
-  context.logger.info('Updated draft', { draftId, subject });
+  context.logger.info('Updated draft', { draftId });

Also applies to: 251-251

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/gmail/drafts.ts` at line 176, The code logs sensitive user
content by calling context.logger.info with the draft subject; remove the
subject from info-level logs and log only non-sensitive identifiers (e.g.,
draftId) instead. Locate the context.logger.info calls in
src/modules/gmail/drafts.ts (the "Retrieved draft" log and the similar
occurrence later) and change them to log only draftId (or masked/hashed
identifiers) at info level; if you still need the subject for troubleshooting,
send it to a debug-level log (context.logger.debug) or store it in a
secure/audited location rather than info.


return {
draftId,
messageId,
threadId,
subject,
from,
to,
cc,
bcc,
date,
body,
isHtml,
snippet: data.message?.snippet ?? '',
};
}

/**
* Update an existing draft in place.
*
* Replaces the draft's content without creating a new draft.
* The draft ID remains the same after the update.
*
* @param options Draft ID and new content
* @param context Gmail API context
* @returns Updated draft info
*
* @example
* ```typescript
* const result = await updateDraft({
* draftId: 'r1234567890',
* to: ['recipient@example.com'],
* subject: 'Updated subject',
* body: 'Updated body content.',
* }, context);
* console.log(result.message); // 'Draft updated successfully'
* ```
*/
export async function updateDraft(
options: UpdateDraftOptions,
context: GmailContext
): Promise<UpdateDraftResult> {
const { draftId, to, cc, bcc, subject, body, isHtml, from, inReplyTo, references } = options;

// Build message options — exactOptionalPropertyTypes requires omitting undefined keys
const msgOptions: Parameters<typeof buildEmailMessage>[0] = { to, subject, body };
if (cc) { msgOptions.cc = cc; }
if (bcc) { msgOptions.bcc = bcc; }
if (isHtml !== undefined) { msgOptions.isHtml = isHtml; }
if (from) { msgOptions.from = from; }
if (inReplyTo) { msgOptions.inReplyTo = inReplyTo; }
if (references) { msgOptions.references = references; }

const emailMessage = buildEmailMessage(msgOptions);

const encodedMessage = encodeToBase64Url(emailMessage);

const response = await context.gmail.users.drafts.update({
userId: 'me',
id: draftId,
requestBody: {
message: {
raw: encodedMessage,
},
},
});

const messageId = response.data.message?.id ?? '';
const threadId = response.data.message?.threadId ?? '';

// Invalidate cache for this draft
await context.cacheManager.invalidate('gmail:list');

context.performanceMonitor.track('gmail:updateDraft', Date.now() - context.startTime);
context.logger.info('Updated draft', { draftId, subject });

return {
draftId,
messageId,
threadId,
message: 'Draft updated successfully',
};
}

/**
* Permanently delete a draft by ID.
*
* This operation cannot be undone. The draft is removed from Gmail.
*
* @param options Draft ID to delete
* @param context Gmail API context
* @returns Deletion confirmation
*
* @example
* ```typescript
* const result = await deleteDraft({ draftId: 'r1234567890' }, context);
* console.log(result.message); // 'Draft r1234567890 deleted'
* ```
*/
export async function deleteDraft(
options: DeleteDraftOptions,
context: GmailContext
): Promise<DeleteDraftResult> {
const { draftId } = options;

await context.gmail.users.drafts.delete({
userId: 'me',
id: draftId,
});

// Invalidate cache
await context.cacheManager.invalidate('gmail:list');

context.performanceMonitor.track('gmail:deleteDraft', Date.now() - context.startTime);
context.logger.info('Deleted draft', { draftId });

return {
draftId,
message: `Draft ${draftId} deleted`,
};
}
13 changes: 13 additions & 0 deletions src/modules/gmail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@

// Types
export type {
// Draft management types
ListDraftsOptions,
ListDraftsResult,
DraftSummary,
GetDraftOptions,
GetDraftResult,
UpdateDraftOptions,
UpdateDraftResult,
DeleteDraftOptions,
DeleteDraftResult,
// List types
ListMessagesOptions,
ListMessagesResult,
Expand Down Expand Up @@ -112,5 +122,8 @@ export { trashMessage, untrashMessage, deleteMessage, markAsRead, markAsUnread,
export { detectReplies } from './detect-replies.js';
export type { DetectRepliesOptions, DetectRepliesResult } from './detect-replies.js';

// Draft management operations
export { listDrafts, getDraft, updateDraft, deleteDraft } from './drafts.js';

// Template operations
export { renderTemplate } from './templates.js';
Loading
Loading