chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
import type { GmailLabelParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailAddLabelTool: ToolConfig<GmailLabelParams, GmailToolResponse> = {
id: 'gmail_add_label',
name: 'Gmail Add Label',
description: 'Add label(s) to a Gmail message',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
messageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the message to add labels to',
},
labelIds: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Comma-separated label IDs to add (e.g., INBOX, Label_123)',
},
},
request: {
url: '/api/tools/gmail/add-label',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailLabelParams) => ({
accessToken: params.accessToken,
messageId: params.messageId,
labelIds: params.labelIds,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to add label(s)',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Email metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Updated email labels' },
},
},
},
}
interface GmailModifyV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailAddLabelV2Tool: ToolConfig<GmailLabelParams, GmailModifyV2Response> = {
id: 'gmail_add_label_v2',
name: 'Gmail Add Label',
description: 'Add label(s) to a Gmail message. Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailAddLabelTool.oauth,
params: gmailAddLabelTool.params,
request: gmailAddLabelTool.request,
transformResponse: async (response) => {
const legacy = await gmailAddLabelTool.transformResponse!(response)
if (!legacy.success) return { success: false, output: {}, error: legacy.error }
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
id: metadata?.id ?? null,
threadId: metadata?.threadId ?? null,
labelIds: metadata?.labelIds ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Updated email labels',
optional: true,
},
},
}
+119
View File
@@ -0,0 +1,119 @@
import type { GmailMarkReadParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailArchiveTool: ToolConfig<GmailMarkReadParams, GmailToolResponse> = {
id: 'gmail_archive',
name: 'Gmail Archive',
description: 'Archive a Gmail message (remove from inbox)',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
messageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the message to archive',
},
},
request: {
url: '/api/tools/gmail/archive',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailMarkReadParams) => ({
accessToken: params.accessToken,
messageId: params.messageId,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to archive email',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Email metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Updated email labels' },
},
},
},
}
interface GmailModifyV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailArchiveV2Tool: ToolConfig<GmailMarkReadParams, GmailModifyV2Response> = {
id: 'gmail_archive_v2',
name: 'Gmail Archive',
description: 'Archive a Gmail message (remove from inbox). Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailArchiveTool.oauth,
params: gmailArchiveTool.params,
request: gmailArchiveTool.request,
transformResponse: async (response) => {
const legacy = await gmailArchiveTool.transformResponse!(response)
if (!legacy.success) return { success: false, output: {}, error: legacy.error }
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
id: metadata?.id ?? null,
threadId: metadata?.threadId ?? null,
labelIds: metadata?.labelIds ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Updated email labels',
optional: true,
},
},
}
+119
View File
@@ -0,0 +1,119 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailCreateLabelParams {
accessToken: string
name: string
messageListVisibility?: string
labelListVisibility?: string
}
interface GmailCreateLabelResponse {
success: boolean
output: {
id: string
name: string
messageListVisibility?: string
labelListVisibility?: string
type?: string
}
}
export const gmailCreateLabelV2Tool: ToolConfig<GmailCreateLabelParams, GmailCreateLabelResponse> =
{
id: 'gmail_create_label_v2',
name: 'Gmail Create Label',
description: 'Create a new label in Gmail',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Display name for the new label',
},
messageListVisibility: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Visibility of messages with this label in the message list (show or hide)',
},
labelListVisibility: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Visibility of the label in the label list (labelShow, labelShowIfUnread, or labelHide)',
},
},
request: {
url: () => `${GMAIL_API_BASE}/labels`,
method: 'POST',
headers: (params: GmailCreateLabelParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params: GmailCreateLabelParams) => {
const body: Record<string, string> = { name: params.name }
if (params.messageListVisibility) {
body.messageListVisibility = params.messageListVisibility
}
if (params.labelListVisibility) {
body.labelListVisibility = params.labelListVisibility
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: { id: '', name: '' },
error: data.error?.message || 'Failed to create label',
}
}
return {
success: true,
output: {
id: data.id,
name: data.name,
messageListVisibility: data.messageListVisibility ?? null,
labelListVisibility: data.labelListVisibility ?? null,
type: data.type ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Label ID' },
name: { type: 'string', description: 'Label display name' },
messageListVisibility: {
type: 'string',
description: 'Visibility of messages with this label',
optional: true,
},
labelListVisibility: {
type: 'string',
description: 'Visibility of the label in the label list',
optional: true,
},
type: { type: 'string', description: 'Label type (system or user)', optional: true },
},
}
+119
View File
@@ -0,0 +1,119 @@
import type { GmailMarkReadParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailDeleteTool: ToolConfig<GmailMarkReadParams, GmailToolResponse> = {
id: 'gmail_delete',
name: 'Gmail Delete',
description: 'Delete a Gmail message (move to trash)',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
messageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the message to delete',
},
},
request: {
url: '/api/tools/gmail/delete',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailMarkReadParams) => ({
accessToken: params.accessToken,
messageId: params.messageId,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to delete email',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Email metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Updated email labels' },
},
},
},
}
interface GmailModifyV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailDeleteV2Tool: ToolConfig<GmailMarkReadParams, GmailModifyV2Response> = {
id: 'gmail_delete_v2',
name: 'Gmail Delete',
description: 'Delete a Gmail message (move to trash). Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailDeleteTool.oauth,
params: gmailDeleteTool.params,
request: gmailDeleteTool.request,
transformResponse: async (response) => {
const legacy = await gmailDeleteTool.transformResponse!(response)
if (!legacy.success) return { success: false, output: {}, error: legacy.error }
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
id: metadata?.id ?? null,
threadId: metadata?.threadId ?? null,
labelIds: metadata?.labelIds ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Updated email labels',
optional: true,
},
},
}
+76
View File
@@ -0,0 +1,76 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailDeleteDraftParams {
accessToken: string
draftId: string
}
interface GmailDeleteDraftResponse {
success: boolean
output: {
deleted: boolean
draftId: string
}
}
export const gmailDeleteDraftV2Tool: ToolConfig<GmailDeleteDraftParams, GmailDeleteDraftResponse> =
{
id: 'gmail_delete_draft_v2',
name: 'Gmail Delete Draft',
description: 'Delete a specific draft from Gmail',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
draftId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the draft to delete',
},
},
request: {
url: (params: GmailDeleteDraftParams) => `${GMAIL_API_BASE}/drafts/${params.draftId}`,
method: 'DELETE',
headers: (params: GmailDeleteDraftParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response, params?: GmailDeleteDraftParams) => {
if (!response.ok) {
const data = await response.json()
return {
success: false,
output: { deleted: false, draftId: params?.draftId ?? '' },
error: data.error?.message || 'Failed to delete draft',
}
}
return {
success: true,
output: {
deleted: true,
draftId: params?.draftId ?? '',
},
}
},
outputs: {
deleted: { type: 'boolean', description: 'Whether the draft was successfully deleted' },
draftId: { type: 'string', description: 'ID of the deleted draft' },
},
}
+76
View File
@@ -0,0 +1,76 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailDeleteLabelParams {
accessToken: string
labelId: string
}
interface GmailDeleteLabelResponse {
success: boolean
output: {
deleted: boolean
labelId: string
}
}
export const gmailDeleteLabelV2Tool: ToolConfig<GmailDeleteLabelParams, GmailDeleteLabelResponse> =
{
id: 'gmail_delete_label_v2',
name: 'Gmail Delete Label',
description: 'Delete a label from Gmail',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
labelId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the label to delete',
},
},
request: {
url: (params: GmailDeleteLabelParams) => `${GMAIL_API_BASE}/labels/${params.labelId}`,
method: 'DELETE',
headers: (params: GmailDeleteLabelParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response, params?: GmailDeleteLabelParams) => {
if (!response.ok) {
const data = await response.json()
return {
success: false,
output: { deleted: false, labelId: params?.labelId ?? '' },
error: data.error?.message || 'Failed to delete label',
}
}
return {
success: true,
output: {
deleted: true,
labelId: params?.labelId ?? '',
},
}
},
outputs: {
deleted: { type: 'boolean', description: 'Whether the label was successfully deleted' },
labelId: { type: 'string', description: 'ID of the deleted label' },
},
}
+186
View File
@@ -0,0 +1,186 @@
import type { GmailSendParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailDraftTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
id: 'gmail_draft',
name: 'Gmail Draft',
description: 'Draft emails using Gmail',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
to: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Recipient email address',
},
subject: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Email subject',
},
body: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email body content',
},
contentType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Content type for the email body (text or html)',
},
threadId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Thread ID to reply to (for threading)',
},
replyToMessageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Gmail message ID to reply to - use the "id" field from Gmail Read results (not the RFC "messageId")',
},
cc: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'CC recipients (comma-separated)',
},
bcc: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'BCC recipients (comma-separated)',
},
attachments: {
type: 'file[]',
required: false,
visibility: 'user-only',
description: 'Files to attach to the email draft',
},
},
request: {
url: '/api/tools/gmail/draft',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailSendParams) => ({
accessToken: params.accessToken,
to: params.to,
subject: params.subject,
body: params.body,
contentType: params.contentType || 'text',
threadId: params.threadId,
replyToMessageId: params.replyToMessageId,
cc: params.cc,
bcc: params.bcc,
attachments: params.attachments,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to create draft',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Draft metadata',
properties: {
id: { type: 'string', description: 'Draft ID' },
message: {
type: 'object',
description: 'Message metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Email labels' },
},
},
},
},
},
}
interface GmailDraftV2Response {
success: boolean
output: {
draftId?: string
messageId?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailDraftV2Tool: ToolConfig<GmailSendParams, GmailDraftV2Response> = {
id: 'gmail_draft_v2',
name: 'Gmail Draft',
description: 'Draft emails using Gmail. Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailDraftTool.oauth,
params: gmailDraftTool.params,
request: gmailDraftTool.request,
transformResponse: async (response) => {
const legacy = await gmailDraftTool.transformResponse!(response)
if (!legacy.success) return { success: false, output: {}, error: legacy.error }
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
draftId: metadata?.id ?? null,
messageId: metadata?.message?.id ?? null,
threadId: metadata?.message?.threadId ?? null,
labelIds: metadata?.message?.labelIds ?? null,
},
}
},
outputs: {
draftId: { type: 'string', description: 'Draft ID', optional: true },
messageId: { type: 'string', description: 'Gmail message ID for the draft', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Email labels',
optional: true,
},
},
}
+162
View File
@@ -0,0 +1,162 @@
import type { ToolConfig } from '@/tools/types'
interface GmailEditDraftParams {
accessToken: string
draftId: string
to: string
subject?: string
body: string
contentType?: string
threadId?: string
replyToMessageId?: string
cc?: string
bcc?: string
attachments?: unknown
}
interface GmailEditDraftResponse {
success: boolean
output: {
draftId?: string
messageId?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailEditDraftV2Tool: ToolConfig<GmailEditDraftParams, GmailEditDraftResponse> = {
id: 'gmail_edit_draft_v2',
name: 'Gmail Edit Draft',
description: 'Update an existing Gmail draft in place without deleting and recreating it.',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
draftId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the draft to update (from Gmail List Drafts or Gmail Get Draft)',
},
to: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Recipient email address',
},
subject: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Email subject',
},
body: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email body content',
},
contentType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Content type for the email body (text or html)',
},
threadId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Thread ID to associate the draft with (for threading)',
},
replyToMessageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Gmail message ID to reply to - use the "id" field from Gmail Read results (not the RFC "messageId")',
},
cc: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'CC recipients (comma-separated)',
},
bcc: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'BCC recipients (comma-separated)',
},
attachments: {
type: 'file[]',
required: false,
visibility: 'user-only',
description: 'Files to attach to the email draft',
},
},
request: {
url: '/api/tools/gmail/edit-draft',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailEditDraftParams) => ({
accessToken: params.accessToken,
draftId: params.draftId?.trim(),
to: params.to,
subject: params.subject,
body: params.body,
contentType: params.contentType || 'text',
threadId: params.threadId,
replyToMessageId: params.replyToMessageId,
cc: params.cc,
bcc: params.bcc,
attachments: params.attachments,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok || !data.success) {
return {
success: false,
output: {},
error: data.error || 'Failed to update draft',
}
}
return {
success: true,
output: {
draftId: data.output?.draftId ?? null,
messageId: data.output?.messageId ?? null,
threadId: data.output?.threadId ?? null,
labelIds: data.output?.labelIds ?? null,
},
}
},
outputs: {
draftId: { type: 'string', description: 'Draft ID', optional: true },
messageId: { type: 'string', description: 'Gmail message ID for the draft', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Email labels',
optional: true,
},
},
}
+117
View File
@@ -0,0 +1,117 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailGetDraftParams {
accessToken: string
draftId: string
}
interface GmailGetDraftResponse {
success: boolean
output: {
id: string
messageId?: string
threadId?: string
to?: string
from?: string
subject?: string
body?: string
labelIds?: string[]
}
}
export const gmailGetDraftV2Tool: ToolConfig<GmailGetDraftParams, GmailGetDraftResponse> = {
id: 'gmail_get_draft_v2',
name: 'Gmail Get Draft',
description: 'Get a specific draft from Gmail by its ID',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
draftId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the draft to retrieve',
},
},
request: {
url: (params: GmailGetDraftParams) => `${GMAIL_API_BASE}/drafts/${params.draftId}?format=full`,
method: 'GET',
headers: (params: GmailGetDraftParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: { id: '' },
error: data.error?.message || 'Failed to get draft',
}
}
const message = data.message || {}
const headers = message.payload?.headers || []
const getHeader = (name: string): string | undefined =>
headers.find((h: Record<string, string>) => h.name.toLowerCase() === name.toLowerCase())
?.value
let body = ''
if (message.payload?.body?.data) {
body = Buffer.from(message.payload.body.data, 'base64').toString()
} else if (message.payload?.parts) {
const textPart = message.payload.parts.find(
(part: Record<string, unknown>) => part.mimeType === 'text/plain'
)
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString()
}
}
return {
success: true,
output: {
id: data.id,
messageId: message.id ?? undefined,
threadId: message.threadId ?? undefined,
to: getHeader('To'),
from: getHeader('From'),
subject: getHeader('Subject'),
body: body || undefined,
labelIds: message.labelIds ?? undefined,
},
}
},
outputs: {
id: { type: 'string', description: 'Draft ID' },
messageId: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
to: { type: 'string', description: 'Recipient email address', optional: true },
from: { type: 'string', description: 'Sender email address', optional: true },
subject: { type: 'string', description: 'Draft subject', optional: true },
body: { type: 'string', description: 'Draft body text', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Draft labels',
optional: true,
},
},
}
+136
View File
@@ -0,0 +1,136 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailGetThreadParams {
accessToken: string
threadId: string
format?: string
}
interface GmailGetThreadResponse {
success: boolean
output: {
id: string
historyId?: string
messages: Array<{
id: string
threadId: string
labelIds?: string[]
snippet?: string
from?: string
to?: string
subject?: string
date?: string
body?: string
}>
}
}
export const gmailGetThreadV2Tool: ToolConfig<GmailGetThreadParams, GmailGetThreadResponse> = {
id: 'gmail_get_thread_v2',
name: 'Gmail Get Thread',
description: 'Get a specific email thread from Gmail, including all messages in the thread',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
threadId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the thread to retrieve',
},
format: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Format to return the messages in (full, metadata, or minimal). Defaults to full.',
},
},
request: {
url: (params: GmailGetThreadParams) => {
const format = params.format || 'full'
return `${GMAIL_API_BASE}/threads/${params.threadId}?format=${format}`
},
method: 'GET',
headers: (params: GmailGetThreadParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: { id: '', messages: [] },
error: data.error?.message || 'Failed to get thread',
}
}
const messages = (data.messages || []).map((message: Record<string, unknown>) => {
const payload = message.payload as Record<string, unknown> | undefined
const headers = (payload?.headers as Array<Record<string, string>>) || []
const getHeader = (name: string): string | undefined =>
headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value
let body = ''
const payloadBody = payload?.body as Record<string, unknown> | undefined
if (payloadBody?.data) {
body = Buffer.from(payloadBody.data as string, 'base64').toString()
} else if (payload?.parts) {
const parts = payload.parts as Array<Record<string, unknown>>
const textPart = parts.find((part) => part.mimeType === 'text/plain')
const textBody = textPart?.body as Record<string, unknown> | undefined
if (textBody?.data) {
body = Buffer.from(textBody.data as string, 'base64').toString()
}
}
return {
id: message.id,
threadId: message.threadId,
labelIds: message.labelIds ?? null,
snippet: message.snippet ?? null,
from: getHeader('From') ?? null,
to: getHeader('To') ?? null,
subject: getHeader('Subject') ?? null,
date: getHeader('Date') ?? null,
body: body || null,
}
})
return {
success: true,
output: {
id: data.id,
historyId: data.historyId ?? null,
messages,
},
}
},
outputs: {
id: { type: 'string', description: 'Thread ID' },
historyId: { type: 'string', description: 'History ID', optional: true },
messages: {
type: 'json',
description:
'Array of messages in the thread with id, from, to, subject, date, body, and labels',
},
},
}
+63
View File
@@ -0,0 +1,63 @@
import { gmailAddLabelTool, gmailAddLabelV2Tool } from '@/tools/gmail/add_label'
import { gmailArchiveTool, gmailArchiveV2Tool } from '@/tools/gmail/archive'
import { gmailCreateLabelV2Tool } from '@/tools/gmail/create_label'
import { gmailDeleteTool, gmailDeleteV2Tool } from '@/tools/gmail/delete'
import { gmailDeleteDraftV2Tool } from '@/tools/gmail/delete_draft'
import { gmailDeleteLabelV2Tool } from '@/tools/gmail/delete_label'
import { gmailDraftTool, gmailDraftV2Tool } from '@/tools/gmail/draft'
import { gmailEditDraftV2Tool } from '@/tools/gmail/edit_draft'
import { gmailGetDraftV2Tool } from '@/tools/gmail/get_draft'
import { gmailGetThreadV2Tool } from '@/tools/gmail/get_thread'
import { gmailListDraftsV2Tool } from '@/tools/gmail/list_drafts'
import { gmailListLabelsV2Tool } from '@/tools/gmail/list_labels'
import { gmailListThreadsV2Tool } from '@/tools/gmail/list_threads'
import { gmailMarkReadTool, gmailMarkReadV2Tool } from '@/tools/gmail/mark_read'
import { gmailMarkUnreadTool, gmailMarkUnreadV2Tool } from '@/tools/gmail/mark_unread'
import { gmailMoveTool, gmailMoveV2Tool } from '@/tools/gmail/move'
import { gmailReadTool, gmailReadV2Tool } from '@/tools/gmail/read'
import { gmailRemoveLabelTool, gmailRemoveLabelV2Tool } from '@/tools/gmail/remove_label'
import { gmailSearchTool, gmailSearchV2Tool } from '@/tools/gmail/search'
import { gmailSendTool, gmailSendV2Tool } from '@/tools/gmail/send'
import { gmailTrashThreadV2Tool } from '@/tools/gmail/trash_thread'
import { gmailUnarchiveTool, gmailUnarchiveV2Tool } from '@/tools/gmail/unarchive'
import { gmailUntrashThreadV2Tool } from '@/tools/gmail/untrash_thread'
import { gmailUpdateLabelV2Tool } from '@/tools/gmail/update_label'
export {
gmailSendTool,
gmailSendV2Tool,
gmailReadTool,
gmailReadV2Tool,
gmailSearchTool,
gmailSearchV2Tool,
gmailDraftTool,
gmailDraftV2Tool,
gmailMoveTool,
gmailMoveV2Tool,
gmailMarkReadTool,
gmailMarkReadV2Tool,
gmailMarkUnreadTool,
gmailMarkUnreadV2Tool,
gmailArchiveTool,
gmailArchiveV2Tool,
gmailUnarchiveTool,
gmailUnarchiveV2Tool,
gmailDeleteTool,
gmailDeleteV2Tool,
gmailAddLabelTool,
gmailAddLabelV2Tool,
gmailRemoveLabelTool,
gmailRemoveLabelV2Tool,
gmailListDraftsV2Tool,
gmailGetDraftV2Tool,
gmailEditDraftV2Tool,
gmailDeleteDraftV2Tool,
gmailCreateLabelV2Tool,
gmailDeleteLabelV2Tool,
gmailListLabelsV2Tool,
gmailGetThreadV2Tool,
gmailListThreadsV2Tool,
gmailTrashThreadV2Tool,
gmailUntrashThreadV2Tool,
gmailUpdateLabelV2Tool,
}
+126
View File
@@ -0,0 +1,126 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailListDraftsParams {
accessToken: string
maxResults?: number
pageToken?: string
query?: string
}
interface GmailListDraftsResponse {
success: boolean
output: {
drafts: Array<{
id: string
messageId: string
threadId: string
}>
resultSizeEstimate: number
nextPageToken?: string
}
}
export const gmailListDraftsV2Tool: ToolConfig<GmailListDraftsParams, GmailListDraftsResponse> = {
id: 'gmail_list_drafts_v2',
name: 'Gmail List Drafts',
description: 'List all drafts in a Gmail account',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of drafts to return (default: 100, max: 500)',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Page token for paginated results',
},
query: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Search query to filter drafts (same syntax as Gmail search)',
},
},
request: {
url: (params: GmailListDraftsParams) => {
const searchParams = new URLSearchParams()
if (params.maxResults) {
searchParams.append('maxResults', Number(params.maxResults).toString())
}
if (params.pageToken) {
searchParams.append('pageToken', params.pageToken)
}
if (params.query) {
searchParams.append('q', params.query)
}
const qs = searchParams.toString()
return `${GMAIL_API_BASE}/drafts${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: GmailListDraftsParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: { drafts: [], resultSizeEstimate: 0 },
error: data.error?.message || 'Failed to list drafts',
}
}
const drafts = (data.drafts || []).map((draft: Record<string, unknown>) => ({
id: draft.id,
messageId: (draft.message as Record<string, unknown>)?.id ?? null,
threadId: (draft.message as Record<string, unknown>)?.threadId ?? null,
}))
return {
success: true,
output: {
drafts,
resultSizeEstimate: data.resultSizeEstimate ?? 0,
nextPageToken: data.nextPageToken ?? null,
},
}
},
outputs: {
drafts: {
type: 'json',
description: 'Array of draft objects with id, messageId, and threadId',
},
resultSizeEstimate: {
type: 'number',
description: 'Estimated total number of drafts',
},
nextPageToken: {
type: 'string',
description: 'Token for fetching the next page of results',
optional: true,
},
},
}
+81
View File
@@ -0,0 +1,81 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailListLabelsParams {
accessToken: string
}
interface GmailListLabelsResponse {
success: boolean
output: {
labels: Array<{
id: string
name: string
type: string
messageListVisibility?: string
labelListVisibility?: string
}>
}
}
export const gmailListLabelsV2Tool: ToolConfig<GmailListLabelsParams, GmailListLabelsResponse> = {
id: 'gmail_list_labels_v2',
name: 'Gmail List Labels',
description: 'List all labels in a Gmail account',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
},
request: {
url: () => `${GMAIL_API_BASE}/labels`,
method: 'GET',
headers: (params: GmailListLabelsParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: { labels: [] },
error: data.error?.message || 'Failed to list labels',
}
}
const labels = (data.labels || []).map((label: Record<string, unknown>) => ({
id: label.id,
name: label.name,
type: label.type ?? null,
messageListVisibility: label.messageListVisibility ?? null,
labelListVisibility: label.labelListVisibility ?? null,
}))
return {
success: true,
output: { labels },
}
},
outputs: {
labels: {
type: 'json',
description: 'Array of label objects with id, name, type, and visibility settings',
},
},
}
+140
View File
@@ -0,0 +1,140 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailListThreadsParams {
accessToken: string
maxResults?: number
pageToken?: string
query?: string
labelIds?: string
}
interface GmailListThreadsResponse {
success: boolean
output: {
threads: Array<{
id: string
snippet: string
historyId: string
}>
resultSizeEstimate: number
nextPageToken?: string
}
}
export const gmailListThreadsV2Tool: ToolConfig<GmailListThreadsParams, GmailListThreadsResponse> =
{
id: 'gmail_list_threads_v2',
name: 'Gmail List Threads',
description: 'List email threads in a Gmail account',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of threads to return (default: 100, max: 500)',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Page token for paginated results',
},
query: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Search query to filter threads (same syntax as Gmail search)',
},
labelIds: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated label IDs to filter threads by',
},
},
request: {
url: (params: GmailListThreadsParams) => {
const searchParams = new URLSearchParams()
if (params.maxResults) {
searchParams.append('maxResults', Number(params.maxResults).toString())
}
if (params.pageToken) {
searchParams.append('pageToken', params.pageToken)
}
if (params.query) {
searchParams.append('q', params.query)
}
if (params.labelIds) {
const labels = params.labelIds.split(',').map((l) => l.trim())
for (const label of labels) {
searchParams.append('labelIds', label)
}
}
const qs = searchParams.toString()
return `${GMAIL_API_BASE}/threads${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: GmailListThreadsParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: { threads: [], resultSizeEstimate: 0 },
error: data.error?.message || 'Failed to list threads',
}
}
const threads = (data.threads || []).map((thread: Record<string, unknown>) => ({
id: thread.id,
snippet: thread.snippet ?? '',
historyId: thread.historyId ?? '',
}))
return {
success: true,
output: {
threads,
resultSizeEstimate: data.resultSizeEstimate ?? 0,
nextPageToken: data.nextPageToken ?? null,
},
}
},
outputs: {
threads: {
type: 'json',
description: 'Array of thread objects with id, snippet, and historyId',
},
resultSizeEstimate: {
type: 'number',
description: 'Estimated total number of threads',
},
nextPageToken: {
type: 'string',
description: 'Token for fetching the next page of results',
optional: true,
},
},
}
+119
View File
@@ -0,0 +1,119 @@
import type { GmailMarkReadParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailMarkReadTool: ToolConfig<GmailMarkReadParams, GmailToolResponse> = {
id: 'gmail_mark_read',
name: 'Gmail Mark as Read',
description: 'Mark a Gmail message as read',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
messageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the message to mark as read',
},
},
request: {
url: '/api/tools/gmail/mark-read',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailMarkReadParams) => ({
accessToken: params.accessToken,
messageId: params.messageId,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to mark email as read',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Email metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Updated email labels' },
},
},
},
}
interface GmailModifyV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailMarkReadV2Tool: ToolConfig<GmailMarkReadParams, GmailModifyV2Response> = {
id: 'gmail_mark_read_v2',
name: 'Gmail Mark as Read',
description: 'Mark a Gmail message as read. Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailMarkReadTool.oauth,
params: gmailMarkReadTool.params,
request: gmailMarkReadTool.request,
transformResponse: async (response) => {
const legacy = await gmailMarkReadTool.transformResponse!(response)
if (!legacy.success) return { success: false, output: {}, error: legacy.error }
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
id: metadata?.id ?? null,
threadId: metadata?.threadId ?? null,
labelIds: metadata?.labelIds ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Updated email labels',
optional: true,
},
},
}
+119
View File
@@ -0,0 +1,119 @@
import type { GmailMarkReadParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailMarkUnreadTool: ToolConfig<GmailMarkReadParams, GmailToolResponse> = {
id: 'gmail_mark_unread',
name: 'Gmail Mark as Unread',
description: 'Mark a Gmail message as unread',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
messageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the message to mark as unread',
},
},
request: {
url: '/api/tools/gmail/mark-unread',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailMarkReadParams) => ({
accessToken: params.accessToken,
messageId: params.messageId,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to mark email as unread',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Email metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Updated email labels' },
},
},
},
}
interface GmailModifyV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailMarkUnreadV2Tool: ToolConfig<GmailMarkReadParams, GmailModifyV2Response> = {
id: 'gmail_mark_unread_v2',
name: 'Gmail Mark as Unread',
description: 'Mark a Gmail message as unread. Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailMarkUnreadTool.oauth,
params: gmailMarkUnreadTool.params,
request: gmailMarkUnreadTool.request,
transformResponse: async (response) => {
const legacy = await gmailMarkUnreadTool.transformResponse!(response)
if (!legacy.success) return { success: false, output: {}, error: legacy.error }
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
id: metadata?.id ?? null,
threadId: metadata?.threadId ?? null,
labelIds: metadata?.labelIds ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Updated email labels',
optional: true,
},
},
}
+135
View File
@@ -0,0 +1,135 @@
import type { GmailMoveParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailMoveTool: ToolConfig<GmailMoveParams, GmailToolResponse> = {
id: 'gmail_move',
name: 'Gmail Move',
description: 'Move emails between Gmail labels/folders',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
messageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the message to move',
},
addLabelIds: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Comma-separated label IDs to add (e.g., INBOX, Label_123)',
},
removeLabelIds: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated label IDs to remove (e.g., INBOX, SPAM)',
},
},
request: {
url: '/api/tools/gmail/move',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailMoveParams) => ({
accessToken: params.accessToken,
messageId: params.messageId,
addLabelIds: params.addLabelIds,
removeLabelIds: params.removeLabelIds,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to move email',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Email metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Updated email labels' },
},
},
},
}
interface GmailModifyV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailMoveV2Tool: ToolConfig<GmailMoveParams, GmailModifyV2Response> = {
id: 'gmail_move_v2',
name: 'Gmail Move',
description: 'Move emails between labels/folders in Gmail. Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailMoveTool.oauth,
params: gmailMoveTool.params,
request: gmailMoveTool.request,
transformResponse: async (response: Response, params?: GmailMoveParams) => {
const legacy = await gmailMoveTool.transformResponse!(response, params)
if (!legacy.success) {
return { success: false, output: {}, error: legacy.error }
}
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
id: metadata?.id ?? null,
threadId: metadata?.threadId ?? null,
labelIds: metadata?.labelIds ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Email labels',
optional: true,
},
},
}
+363
View File
@@ -0,0 +1,363 @@
import { createLogger } from '@sim/logger'
import type { GmailAttachment, GmailReadParams, GmailToolResponse } from '@/tools/gmail/types'
import {
createMessagesSummary,
GMAIL_API_BASE,
processMessage,
processMessageForSummary,
} from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('GmailReadTool')
export const gmailReadTool: ToolConfig<GmailReadParams, GmailToolResponse> = {
id: 'gmail_read',
name: 'Gmail Read',
description: 'Read emails from Gmail',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
messageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Gmail message ID to read (e.g., 18f1a2b3c4d5e6f7)',
},
folder: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Folder/label to read emails from (e.g., INBOX, SENT, DRAFT, TRASH, SPAM, or custom label name)',
},
unreadOnly: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Set to true to only retrieve unread messages',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of messages to retrieve (default: 1, max: 10)',
},
includeAttachments: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Set to true to download and include email attachments',
},
},
request: {
url: (params) => {
// If a specific message ID is provided, fetch that message directly with full format
if (params.messageId) {
return `${GMAIL_API_BASE}/messages/${params.messageId}?format=full`
}
// Otherwise, list messages from the specified folder or INBOX by default
const url = new URL(`${GMAIL_API_BASE}/messages`)
// Build query parameters for the folder/label
const queryParams = []
// Add unread filter if specified
if (params.unreadOnly) {
queryParams.push('is:unread')
}
if (params.folder) {
// If it's a system label like INBOX, SENT, etc., use it directly
if (['INBOX', 'SENT', 'DRAFT', 'TRASH', 'SPAM'].includes(params.folder)) {
queryParams.push(`in:${params.folder.toLowerCase()}`)
} else {
// Otherwise, it's a user-defined label
queryParams.push(`label:${params.folder}`)
}
} else {
// Default to INBOX if no folder is specified
queryParams.push('in:inbox')
}
// Only add query if we have parameters
if (queryParams.length > 0) {
url.searchParams.append('q', queryParams.join(' '))
}
// Set max results (default to 1 for simplicity, max 10)
const maxResults = params.maxResults ? Math.min(Number(params.maxResults), 10) : 1
url.searchParams.append('maxResults', maxResults.toString())
return url.toString()
},
method: 'GET',
headers: (params: GmailReadParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response, params?: GmailReadParams) => {
const data = await response.json()
// If we're fetching a single message directly (by ID)
if (params?.messageId) {
return await processMessage(data, params)
}
// If we're listing messages, we need to fetch each message's details
if (data.messages && Array.isArray(data.messages)) {
// Return a message if no emails found
if (data.messages.length === 0) {
return {
success: true,
output: {
content: 'No messages found in the selected folder.',
metadata: {
results: [], // Use SearchMetadata format
},
},
}
}
// For agentic workflows, we'll fetch the first message by default
// If maxResults > 1, we'll return a summary of messages found
const maxResults = params?.maxResults ? Math.min(Number(params.maxResults), 10) : 1
if (maxResults === 1) {
try {
// Get the first message details
const messageId = data.messages[0].id
const messageResponse = await fetch(
`${GMAIL_API_BASE}/messages/${messageId}?format=full`,
{
headers: {
Authorization: `Bearer ${params?.accessToken || ''}`,
'Content-Type': 'application/json',
},
}
)
if (!messageResponse.ok) {
const errorData = await messageResponse.json()
throw new Error(errorData.error?.message || 'Failed to fetch message details')
}
const message = await messageResponse.json()
return await processMessage(message, params)
} catch (error: any) {
return {
success: true,
output: {
content: `Found messages but couldn't retrieve details: ${error.message || 'Unknown error'}`,
metadata: {
results: data.messages.map((msg: any) => ({
id: msg.id,
threadId: msg.threadId,
})),
},
},
}
}
} else {
// If maxResults > 1, fetch details for all messages
try {
const messagePromises = data.messages.slice(0, maxResults).map(async (msg: any) => {
const messageResponse = await fetch(
`${GMAIL_API_BASE}/messages/${msg.id}?format=full`,
{
headers: {
Authorization: `Bearer ${params?.accessToken || ''}`,
'Content-Type': 'application/json',
},
}
)
if (!messageResponse.ok) {
throw new Error(`Failed to fetch details for message ${msg.id}`)
}
return await messageResponse.json()
})
const messages = await Promise.all(messagePromises)
// Create summary from processed messages first
const summaryMessages = messages.map(processMessageForSummary)
const allAttachments: GmailAttachment[] = []
if (params?.includeAttachments) {
for (const msg of messages) {
try {
const processedResult = await processMessage(msg, params)
if (
processedResult.output.attachments &&
processedResult.output.attachments.length > 0
) {
allAttachments.push(...processedResult.output.attachments)
}
} catch (error: any) {
logger.error(`Error processing message ${msg.id} for attachments:`, error)
}
}
}
return {
success: true,
output: {
content: createMessagesSummary(summaryMessages),
metadata: {
results: summaryMessages.map((msg) => ({
id: msg.id,
threadId: msg.threadId,
subject: msg.subject,
from: msg.from,
to: msg.to,
date: msg.date,
})),
},
attachments: allAttachments,
},
}
} catch (error: any) {
return {
success: true,
output: {
content: `Found ${data.messages.length} messages but couldn't retrieve all details: ${error.message || 'Unknown error'}`,
metadata: {
results: data.messages.map((msg: any) => ({
id: msg.id,
threadId: msg.threadId,
})),
},
attachments: [],
},
}
}
}
}
// Fallback for unexpected response format
return {
success: true,
output: {
content: 'Unexpected response format from Gmail API',
metadata: {
results: [],
},
},
}
},
outputs: {
content: { type: 'string', description: 'Text content of the email' },
metadata: { type: 'json', description: 'Metadata of the email' },
attachments: { type: 'file[]', description: 'Attachments of the email' },
},
}
interface GmailReadV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
from?: string
to?: string
subject?: string
date?: string
body?: string
hasAttachments?: boolean
attachmentCount?: number
attachments?: GmailAttachment[]
results?: Array<Record<string, any>>
}
}
export const gmailReadV2Tool: ToolConfig<GmailReadParams, GmailReadV2Response> = {
id: 'gmail_read_v2',
name: 'Gmail Read',
description: 'Read emails from Gmail. Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailReadTool.oauth,
params: gmailReadTool.params,
request: gmailReadTool.request,
transformResponse: async (response: Response, params?: GmailReadParams) => {
const legacy = await gmailReadTool.transformResponse!(response, params)
if (!legacy.success) {
return {
success: false,
output: {},
error: legacy.error,
}
}
const metadata = (legacy.output.metadata || {}) as any
return {
success: true,
output: {
id: metadata.id,
threadId: metadata.threadId,
labelIds: metadata.labelIds,
from: metadata.from,
to: metadata.to,
subject: metadata.subject,
date: metadata.date,
body: legacy.output.content,
hasAttachments: metadata.hasAttachments,
attachmentCount: metadata.attachmentCount,
attachments: legacy.output.attachments || [],
results: metadata.results,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Email labels',
optional: true,
},
from: { type: 'string', description: 'Sender email address', optional: true },
to: { type: 'string', description: 'Recipient email address', optional: true },
subject: { type: 'string', description: 'Email subject', optional: true },
date: { type: 'string', description: 'Email date', optional: true },
body: {
type: 'string',
description: 'Email body text (best-effort plain text)',
optional: true,
},
hasAttachments: {
type: 'boolean',
description: 'Whether the email has attachments',
optional: true,
},
attachmentCount: { type: 'number', description: 'Number of attachments', optional: true },
attachments: {
type: 'file[]',
description: 'Downloaded attachments (if enabled)',
optional: true,
},
results: {
type: 'json',
description: 'Summary results when reading multiple messages',
optional: true,
},
},
}
+126
View File
@@ -0,0 +1,126 @@
import type { GmailLabelParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailRemoveLabelTool: ToolConfig<GmailLabelParams, GmailToolResponse> = {
id: 'gmail_remove_label',
name: 'Gmail Remove Label',
description: 'Remove label(s) from a Gmail message',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
messageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the message to remove labels from',
},
labelIds: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Comma-separated label IDs to remove (e.g., INBOX, Label_123)',
},
},
request: {
url: '/api/tools/gmail/remove-label',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailLabelParams) => ({
accessToken: params.accessToken,
messageId: params.messageId,
labelIds: params.labelIds,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to remove label(s)',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Email metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Updated email labels' },
},
},
},
}
interface GmailModifyV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailRemoveLabelV2Tool: ToolConfig<GmailLabelParams, GmailModifyV2Response> = {
id: 'gmail_remove_label_v2',
name: 'Gmail Remove Label',
description: 'Remove label(s) from a Gmail message. Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailRemoveLabelTool.oauth,
params: gmailRemoveLabelTool.params,
request: gmailRemoveLabelTool.request,
transformResponse: async (response) => {
const legacy = await gmailRemoveLabelTool.transformResponse!(response)
if (!legacy.success) return { success: false, output: {}, error: legacy.error }
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
id: metadata?.id ?? null,
threadId: metadata?.threadId ?? null,
labelIds: metadata?.labelIds ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Updated email labels',
optional: true,
},
},
}
+192
View File
@@ -0,0 +1,192 @@
import { createLogger } from '@sim/logger'
import type { GmailSearchParams, GmailToolResponse } from '@/tools/gmail/types'
import {
createMessagesSummary,
GMAIL_API_BASE,
processMessageForSummary,
} from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('GmailSearchTool')
export const gmailSearchTool: ToolConfig<GmailSearchParams, GmailToolResponse> = {
id: 'gmail_search',
name: 'Gmail Search',
description: 'Search emails in Gmail',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
query: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Search query for emails',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of results to return (e.g., 10, 25, 50)',
},
},
request: {
url: (params: GmailSearchParams) => {
const searchParams = new URLSearchParams()
searchParams.append('q', params.query)
if (params.maxResults) {
searchParams.append('maxResults', Number(params.maxResults).toString())
}
return `${GMAIL_API_BASE}/messages?${searchParams.toString()}`
},
method: 'GET',
headers: (params: GmailSearchParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response, params) => {
const data = await response.json()
if (!data.messages || data.messages.length === 0) {
return {
success: true,
output: {
content: 'No messages found matching your search query.',
metadata: {
results: [],
},
},
}
}
try {
// Fetch full message details for each result
const messagePromises = data.messages.map(async (msg: any) => {
const messageResponse = await fetch(`${GMAIL_API_BASE}/messages/${msg.id}?format=full`, {
headers: {
Authorization: `Bearer ${params?.accessToken || ''}`,
'Content-Type': 'application/json',
},
})
if (!messageResponse.ok) {
throw new Error(`Failed to fetch details for message ${msg.id}`)
}
return await messageResponse.json()
})
const messages = await Promise.all(messagePromises)
// Process all messages and create a summary
const processedMessages = messages.map(processMessageForSummary)
return {
success: true,
output: {
content: createMessagesSummary(processedMessages),
metadata: {
results: processedMessages.map((msg) => ({
id: msg.id,
threadId: msg.threadId,
subject: msg.subject,
from: msg.from,
date: msg.date,
snippet: msg.snippet,
})),
},
},
}
} catch (error: any) {
logger.error('Error fetching message details:', error)
return {
success: true,
output: {
content: `Found ${data.messages.length} messages but couldn't retrieve all details: ${error.message || 'Unknown error'}`,
metadata: {
results: data.messages.map((msg: any) => ({
id: msg.id,
threadId: msg.threadId,
})),
},
},
}
}
},
outputs: {
content: { type: 'string', description: 'Search results summary' },
metadata: {
type: 'object',
description: 'Search metadata',
properties: {
results: {
type: 'array',
description: 'Array of search results',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
subject: { type: 'string', description: 'Email subject' },
from: { type: 'string', description: 'Sender email address' },
date: { type: 'string', description: 'Email date' },
snippet: { type: 'string', description: 'Email snippet/preview' },
},
},
},
},
},
},
}
interface GmailSearchV2Response {
success: boolean
output: {
results: Array<Record<string, any>>
}
}
export const gmailSearchV2Tool: ToolConfig<GmailSearchParams, GmailSearchV2Response> = {
id: 'gmail_search_v2',
name: 'Gmail Search',
description: 'Search emails in Gmail. Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailSearchTool.oauth,
params: gmailSearchTool.params,
request: gmailSearchTool.request,
transformResponse: async (response: Response, params?: GmailSearchParams) => {
const legacy = await gmailSearchTool.transformResponse!(response, params)
if (!legacy.success) {
return {
success: false,
output: { results: [] },
error: legacy.error,
}
}
const metadata = (legacy.output.metadata || {}) as any
return {
success: true,
output: {
results: metadata.results || [],
},
}
},
outputs: {
results: { type: 'json', description: 'Array of search results' },
},
}
+183
View File
@@ -0,0 +1,183 @@
import type { GmailSendParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailSendTool: ToolConfig<GmailSendParams, GmailToolResponse> = {
id: 'gmail_send',
name: 'Gmail Send',
description: 'Send emails using Gmail',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
to: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Recipient email address',
},
subject: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Email subject',
},
body: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email body content',
},
contentType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Content type for the email body (text or html)',
},
threadId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Thread ID to reply to (for threading)',
},
replyToMessageId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Gmail message ID to reply to - use the "id" field from Gmail Read results (not the RFC "messageId")',
},
cc: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'CC recipients (comma-separated)',
},
bcc: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'BCC recipients (comma-separated)',
},
attachments: {
type: 'file[]',
required: false,
visibility: 'user-only',
description: 'Files to attach to the email',
},
},
request: {
url: '/api/tools/gmail/send',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailSendParams) => ({
accessToken: params.accessToken,
to: params.to,
subject: params.subject,
body: params.body,
contentType: params.contentType || 'text',
threadId: params.threadId,
replyToMessageId: params.replyToMessageId,
cc: params.cc,
bcc: params.bcc,
attachments: params.attachments,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to send email',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Email metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Email labels' },
},
},
},
}
interface GmailSendV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailSendV2Tool: ToolConfig<GmailSendParams, GmailSendV2Response> = {
id: 'gmail_send_v2',
name: 'Gmail Send',
description: 'Send emails using Gmail. Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailSendTool.oauth,
params: gmailSendTool.params,
request: gmailSendTool.request,
transformResponse: async (response) => {
const legacy = await gmailSendTool.transformResponse!(response)
if (!legacy.success) {
return {
success: false,
output: {},
error: legacy.error,
}
}
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
id: metadata?.id ?? null,
threadId: metadata?.threadId ?? null,
labelIds: metadata?.labelIds ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Email labels',
optional: true,
},
},
}
+78
View File
@@ -0,0 +1,78 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailTrashThreadParams {
accessToken: string
threadId: string
}
interface GmailTrashThreadResponse {
success: boolean
output: {
id: string
trashed: boolean
}
}
export const gmailTrashThreadV2Tool: ToolConfig<GmailTrashThreadParams, GmailTrashThreadResponse> =
{
id: 'gmail_trash_thread_v2',
name: 'Gmail Trash Thread',
description: 'Move an email thread to trash in Gmail',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
threadId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the thread to trash',
},
},
request: {
url: (params: GmailTrashThreadParams) => `${GMAIL_API_BASE}/threads/${params.threadId}/trash`,
method: 'POST',
headers: (params: GmailTrashThreadParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: { id: '', trashed: false },
error: data.error?.message || 'Failed to trash thread',
}
}
return {
success: true,
output: {
id: data.id,
trashed: true,
},
}
},
outputs: {
id: { type: 'string', description: 'Thread ID' },
trashed: { type: 'boolean', description: 'Whether the thread was successfully trashed' },
},
}
+131
View File
@@ -0,0 +1,131 @@
import type { UserFile } from '@/executor/types'
import type { ToolResponse } from '@/tools/types'
// Base parameters shared by all operations
interface BaseGmailParams {
accessToken: string
}
// Send operation parameters
export interface GmailSendParams extends BaseGmailParams {
to: string
cc?: string
bcc?: string
subject?: string
body: string
contentType?: 'text' | 'html'
threadId?: string
replyToMessageId?: string
attachments?: UserFile[]
}
// Read operation parameters
export interface GmailReadParams extends BaseGmailParams {
messageId: string
folder: string
unreadOnly?: boolean
maxResults?: number
includeAttachments?: boolean
}
// Search operation parameters
export interface GmailSearchParams extends BaseGmailParams {
query: string
maxResults?: number
}
// Move operation parameters
export interface GmailMoveParams extends BaseGmailParams {
messageId: string
addLabelIds: string
removeLabelIds?: string
}
// Mark as read/unread parameters (reuses simple messageId pattern)
export interface GmailMarkReadParams extends BaseGmailParams {
messageId: string
}
// Label management parameters
export interface GmailLabelParams extends BaseGmailParams {
messageId: string
labelIds: string
}
// Union type for all Gmail tool parameters
export type GmailToolParams =
| GmailSendParams
| GmailReadParams
| GmailSearchParams
| GmailMoveParams
| GmailMarkReadParams
| GmailLabelParams
// Response metadata
interface BaseGmailMetadata {
id?: string
threadId?: string
labelIds?: string[]
}
interface EmailMetadata extends BaseGmailMetadata {
from?: string
to?: string
subject?: string
date?: string
hasAttachments?: boolean
attachmentCount?: number
}
interface SearchMetadata extends BaseGmailMetadata {
results: Array<{
id: string
threadId: string
}>
}
// Response format
export interface GmailToolResponse extends ToolResponse {
output: {
content: string
metadata: EmailMetadata | SearchMetadata
attachments?: GmailAttachment[]
}
}
// Email Message Interface
export interface GmailMessage {
id: string
threadId: string
labelIds: string[]
snippet: string
payload: {
headers: Array<{
name: string
value: string
}>
body: {
data?: string
attachmentId?: string
size?: number
}
parts?: Array<{
mimeType: string
filename?: string
body: {
data?: string
attachmentId?: string
size?: number
}
parts?: Array<any>
}>
}
}
// Gmail Attachment Interface (for processed attachments)
export interface GmailAttachment {
name: string
data: string
mimeType: string
size: number
}
+119
View File
@@ -0,0 +1,119 @@
import type { GmailMarkReadParams, GmailToolResponse } from '@/tools/gmail/types'
import type { ToolConfig } from '@/tools/types'
export const gmailUnarchiveTool: ToolConfig<GmailMarkReadParams, GmailToolResponse> = {
id: 'gmail_unarchive',
name: 'Gmail Unarchive',
description: 'Unarchive a Gmail message (move back to inbox)',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
messageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the message to unarchive',
},
},
request: {
url: '/api/tools/gmail/unarchive',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: GmailMarkReadParams) => ({
accessToken: params.accessToken,
messageId: params.messageId,
}),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to unarchive email',
metadata: {},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Email metadata',
properties: {
id: { type: 'string', description: 'Gmail message ID' },
threadId: { type: 'string', description: 'Gmail thread ID' },
labelIds: { type: 'array', items: { type: 'string' }, description: 'Updated email labels' },
},
},
},
}
interface GmailModifyV2Response {
success: boolean
output: {
id?: string
threadId?: string
labelIds?: string[]
}
}
export const gmailUnarchiveV2Tool: ToolConfig<GmailMarkReadParams, GmailModifyV2Response> = {
id: 'gmail_unarchive_v2',
name: 'Gmail Unarchive',
description: 'Unarchive a Gmail message (move back to inbox). Returns API-aligned fields only.',
version: '2.0.0',
oauth: gmailUnarchiveTool.oauth,
params: gmailUnarchiveTool.params,
request: gmailUnarchiveTool.request,
transformResponse: async (response) => {
const legacy = await gmailUnarchiveTool.transformResponse!(response)
if (!legacy.success) return { success: false, output: {}, error: legacy.error }
const metadata = legacy.output.metadata as any
return {
success: true,
output: {
id: metadata?.id ?? null,
threadId: metadata?.threadId ?? null,
labelIds: metadata?.labelIds ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Gmail message ID', optional: true },
threadId: { type: 'string', description: 'Gmail thread ID', optional: true },
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Updated email labels',
optional: true,
},
},
}
+84
View File
@@ -0,0 +1,84 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
interface GmailUntrashThreadParams {
accessToken: string
threadId: string
}
interface GmailUntrashThreadResponse {
success: boolean
output: {
id: string
untrashed: boolean
}
}
export const gmailUntrashThreadV2Tool: ToolConfig<
GmailUntrashThreadParams,
GmailUntrashThreadResponse
> = {
id: 'gmail_untrash_thread_v2',
name: 'Gmail Untrash Thread',
description: 'Remove an email thread from trash in Gmail',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
threadId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the thread to untrash',
},
},
request: {
url: (params: GmailUntrashThreadParams) =>
`${GMAIL_API_BASE}/threads/${params.threadId}/untrash`,
method: 'POST',
headers: (params: GmailUntrashThreadParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: { id: '', untrashed: false },
error: data.error?.message || 'Failed to untrash thread',
}
}
return {
success: true,
output: {
id: data.id,
untrashed: true,
},
}
},
outputs: {
id: { type: 'string', description: 'Thread ID' },
untrashed: {
type: 'boolean',
description: 'Whether the thread was successfully removed from trash',
},
},
}
+136
View File
@@ -0,0 +1,136 @@
import { GMAIL_API_BASE } from '@/tools/gmail/utils'
import type { ToolConfig } from '@/tools/types'
/**
* Tool-only (not exposed in the Gmail block UI). Mirrors the existing
* `gmail_create_label_v2` / `gmail_delete_label_v2` / `gmail_list_labels_v2`
* pattern — these are programmatic/agent-facing tools used by Mothership
* and MCP, not visual workflow operations.
*/
interface GmailUpdateLabelParams {
accessToken: string
labelId: string
name?: string
messageListVisibility?: string
labelListVisibility?: string
}
interface GmailUpdateLabelResponse {
success: boolean
output: {
id: string
name?: string
messageListVisibility?: string | null
labelListVisibility?: string | null
type?: string | null
}
}
export const gmailUpdateLabelV2Tool: ToolConfig<GmailUpdateLabelParams, GmailUpdateLabelResponse> =
{
id: 'gmail_update_label_v2',
name: 'Gmail Update Label',
description:
'Update a Gmail label in place (rename or change visibility) without recreating it',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-email',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'Access token for Gmail API',
},
labelId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the label to update (from Gmail List Labels)',
},
name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New display name for the label',
},
messageListVisibility: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Visibility of messages with this label in the message list (show or hide)',
},
labelListVisibility: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Visibility of the label in the label list (labelShow, labelShowIfUnread, or labelHide)',
},
},
request: {
url: (params: GmailUpdateLabelParams) =>
`${GMAIL_API_BASE}/labels/${encodeURIComponent(params.labelId.trim())}`,
method: 'PATCH',
headers: (params: GmailUpdateLabelParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params: GmailUpdateLabelParams) => {
const body: Record<string, string> = {}
if (params.name) body.name = params.name
if (params.messageListVisibility) {
body.messageListVisibility = params.messageListVisibility
}
if (params.labelListVisibility) {
body.labelListVisibility = params.labelListVisibility
}
return body
},
},
transformResponse: async (response: Response, params?: GmailUpdateLabelParams) => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
output: { id: params?.labelId ?? '' },
error: data.error?.message || 'Failed to update label',
}
}
return {
success: true,
output: {
id: data.id,
name: data.name ?? null,
messageListVisibility: data.messageListVisibility ?? null,
labelListVisibility: data.labelListVisibility ?? null,
type: data.type ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Label ID' },
name: { type: 'string', description: 'Label display name', optional: true },
messageListVisibility: {
type: 'string',
description: 'Visibility of messages with this label',
optional: true,
},
labelListVisibility: {
type: 'string',
description: 'Visibility of the label in the label list',
optional: true,
},
type: { type: 'string', description: 'Label type (system or user)', optional: true },
},
}
+324
View File
@@ -0,0 +1,324 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildMimeMessage,
buildSimpleEmailMessage,
encodeRfc2047,
escapeHtml,
htmlToPlainText,
plainTextToHtml,
sanitizeHeaderValue,
} from './utils'
function decodeSimpleMessage(encoded: string): string {
return Buffer.from(encoded, 'base64url').toString('utf-8')
}
/**
* Extract and base64-decode the body of a specific MIME part identified by its
* Content-Type prefix (e.g. `text/plain`, `text/html`). Returns the decoded
* UTF-8 string.
*/
function decodePart(mime: string, contentTypePrefix: string): string {
const partRegex = new RegExp(
`Content-Type: ${contentTypePrefix}[^\\n]*\\nContent-Transfer-Encoding: base64\\n\\n([\\s\\S]*?)\\n\\n--`
)
const match = mime.match(partRegex)
if (!match) throw new Error(`No ${contentTypePrefix} part found`)
return Buffer.from(match[1].replace(/\n/g, ''), 'base64').toString('utf-8')
}
describe('encodeRfc2047', () => {
it('returns ASCII text unchanged', () => {
expect(encodeRfc2047('Simple ASCII Subject')).toBe('Simple ASCII Subject')
})
it('returns empty string unchanged', () => {
expect(encodeRfc2047('')).toBe('')
})
it('encodes emojis as RFC 2047 base64', () => {
const result = encodeRfc2047('Time to Stretch! 🧘')
expect(result).toBe('=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?=')
})
it('round-trips non-ASCII subjects correctly', () => {
const subjects = ['Hello 世界', 'Café résumé', '🎉🎊🎈 Party!', '今週のミーティング']
for (const subject of subjects) {
const encoded = encodeRfc2047(subject)
const match = encoded.match(/^=\?UTF-8\?B\?(.+)\?=$/)
expect(match).not.toBeNull()
const decoded = Buffer.from(match![1], 'base64').toString('utf-8')
expect(decoded).toBe(subject)
}
})
it('does not double-encode already-encoded subjects', () => {
const alreadyEncoded = '=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?='
expect(encodeRfc2047(alreadyEncoded)).toBe(alreadyEncoded)
})
})
describe('sanitizeHeaderValue', () => {
it('collapses embedded CRLF to a single space', () => {
expect(sanitizeHeaderValue('foo\r\nBcc: attacker@example.com')).toBe(
'foo Bcc: attacker@example.com'
)
})
it('collapses embedded bare LF or CR to a single space', () => {
expect(sanitizeHeaderValue('foo\nbar')).toBe('foo bar')
expect(sanitizeHeaderValue('foo\rbar')).toBe('foo bar')
})
it('collapses consecutive newlines to a single space', () => {
expect(sanitizeHeaderValue('foo\r\n\r\nbar')).toBe('foo bar')
})
it('leaves ordinary values unchanged', () => {
expect(sanitizeHeaderValue('a@example.com, b@example.com')).toBe('a@example.com, b@example.com')
})
})
describe('escapeHtml', () => {
it('escapes the five HTML special characters', () => {
expect(escapeHtml(`<script>alert("x & y's")</script>`)).toBe(
'&lt;script&gt;alert(&quot;x &amp; y&#39;s&quot;)&lt;/script&gt;'
)
})
})
describe('plainTextToHtml', () => {
it('converts newlines to <br> without paragraph margins', () => {
const html = plainTextToHtml('Hi Janice,\n\nHope you are well.\nSecond line.')
expect(html).not.toContain('<p>')
expect(html).toContain('Hi Janice,<br><br>Hope you are well.<br>Second line.')
})
it('escapes HTML in the source text', () => {
expect(plainTextToHtml('<b>bold</b>')).toContain('&lt;b&gt;bold&lt;/b&gt;')
})
})
describe('htmlToPlainText', () => {
it('strips tags and decodes entities', () => {
const result = htmlToPlainText('<p>Hi &amp; bye</p><p>Line<br>break</p>')
expect(result).toBe('Hi & bye\n\nLine\nbreak')
})
it('drops <style> and <script> contents', () => {
expect(htmlToPlainText('<style>p{}</style><p>Hi</p>')).toBe('Hi')
})
it('does not double-decode compound entities like &amp;lt;', () => {
expect(htmlToPlainText('<p>&amp;lt; is the literal &lt; entity</p>')).toBe(
'&lt; is the literal < entity'
)
})
it('decodes decimal and hexadecimal numeric entities', () => {
expect(htmlToPlainText('<p>&#8220;hi&#8221; and&#x2019;s</p>')).toBe(
'\u201chi\u201d and\u2019s'
)
})
it('preserves &#160; (non-breaking space) as U+00A0 for fidelity in plain-text output', () => {
expect(htmlToPlainText('<p>a&#160;b</p>')).toBe('a\u00a0b')
})
it('elides anchor URLs that exactly match link text, and drops bare # anchors', () => {
expect(
htmlToPlainText('<p>Visit <a href="https://example.com">https://example.com</a></p>')
).toBe('Visit https://example.com')
expect(htmlToPlainText('<p><a href="#section">Anchor</a></p>')).toBe('Anchor')
})
})
describe('buildSimpleEmailMessage', () => {
it('emits multipart/alternative with text/plain then text/html for plain-text input', () => {
const encoded = buildSimpleEmailMessage({
to: 'a@example.com',
subject: 'Hi',
body: 'Hi Janice,\n\nQuick question.',
})
const decoded = decodeSimpleMessage(encoded)
expect(decoded).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/)
const plainIdx = decoded.indexOf('text/plain')
const htmlIdx = decoded.indexOf('text/html')
expect(plainIdx).toBeGreaterThan(-1)
expect(htmlIdx).toBeGreaterThan(plainIdx)
expect(decodePart(decoded, 'text/plain')).toBe('Hi Janice,\n\nQuick question.')
expect(decodePart(decoded, 'text/html')).toContain('Hi Janice,<br><br>Quick question.')
})
it('encodes bodies as base64 so UTF-8 (emoji, accents) round-trips cleanly', () => {
const body = 'Café 🎉 — résumé'
const encoded = buildSimpleEmailMessage({
to: 'a@example.com',
subject: 'Hi',
body,
})
const decoded = decodeSimpleMessage(encoded)
expect(decoded).toContain('Content-Transfer-Encoding: base64')
expect(decodePart(decoded, 'text/plain')).toBe(body)
expect(decodePart(decoded, 'text/html')).toContain('Café 🎉 — résumé')
})
it('uses the supplied HTML body and derives a plain-text fallback when contentType is html', () => {
const encoded = buildSimpleEmailMessage({
to: 'a@example.com',
subject: 'Hi',
body: '<p>Hello <b>there</b></p>',
contentType: 'html',
})
const decoded = decodeSimpleMessage(encoded)
expect(decodePart(decoded, 'text/html')).toBe('<p>Hello <b>there</b></p>')
expect(decodePart(decoded, 'text/plain')).toBe('Hello there')
})
it('includes threading headers when replying', () => {
const encoded = buildSimpleEmailMessage({
to: 'a@example.com',
body: 'reply',
inReplyTo: '<msg-1@example.com>',
references: '<root@example.com>',
})
const decoded = decodeSimpleMessage(encoded)
expect(decoded).toContain('In-Reply-To: <msg-1@example.com>')
expect(decoded).toContain('References: <root@example.com> <msg-1@example.com>')
})
it('strips embedded CRLF from to/cc/bcc/subject/inReplyTo/references so no extra header line is produced', () => {
const injected = 'innocuous\r\nBcc: attacker@example.com'
const encoded = buildSimpleEmailMessage({
to: injected,
cc: injected,
bcc: injected,
subject: injected,
body: 'hello',
inReplyTo: injected,
references: injected,
})
const decoded = decodeSimpleMessage(encoded)
const lines = decoded.split('\n')
const bccLines = lines.filter((line) => line.startsWith('Bcc:'))
// Exactly one Bcc header line (the legitimate one), holding the sanitized, single-line value.
expect(bccLines).toHaveLength(1)
expect(bccLines[0]).toBe('Bcc: innocuous Bcc: attacker@example.com')
expect(lines).not.toContain('Bcc: attacker@example.com')
})
it('preserves legitimate ASCII, Unicode, and multi-recipient values unchanged', () => {
const encoded = buildSimpleEmailMessage({
to: 'a@example.com, b@example.com',
cc: 'c@example.com',
subject: 'Café meeting 🎉',
body: 'hello',
})
const decoded = decodeSimpleMessage(encoded)
expect(decoded).toContain('To: a@example.com, b@example.com')
expect(decoded).toContain('Cc: c@example.com')
expect(decoded).toContain(`Subject: ${encodeRfc2047('Café meeting 🎉')}`)
})
})
describe('buildMimeMessage', () => {
it('nests multipart/alternative inside multipart/mixed when attachments are present', () => {
const message = buildMimeMessage({
to: 'a@example.com',
subject: 'Hi',
body: 'Hello',
attachments: [
{
filename: 'note.txt',
mimeType: 'text/plain',
content: Buffer.from('hi'),
},
],
})
expect(message).toMatch(/Content-Type: multipart\/mixed; boundary="([^"]+)"/)
expect(message).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/)
expect(message).toContain('Content-Disposition: attachment; filename="note.txt"')
expect(decodePart(message, 'text/plain')).toBe('Hello')
expect(decodePart(message, 'text/html')).toContain('Hello')
})
it('emits multipart/alternative without multipart/mixed when no attachments', () => {
const message = buildMimeMessage({
to: 'a@example.com',
subject: 'Hi',
body: 'Hello',
})
expect(message).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/)
expect(message).not.toContain('multipart/mixed')
})
it('strips embedded CRLF from header fields and the attachment filename', () => {
const injected = 'innocuous\r\nBcc: attacker@example.com'
const message = buildMimeMessage({
to: injected,
cc: injected,
bcc: injected,
subject: injected,
body: 'hello',
inReplyTo: injected,
references: injected,
attachments: [
{
filename: injected,
mimeType: 'text/plain',
content: Buffer.from('hi'),
},
],
})
const lines = message.split('\n')
const bccLines = lines.filter((line) => line.startsWith('Bcc:'))
expect(bccLines).toHaveLength(1)
expect(bccLines[0]).toBe('Bcc: innocuous Bcc: attacker@example.com')
expect(lines).not.toContain('Bcc: attacker@example.com')
expect(message).toContain(
'Content-Disposition: attachment; filename="innocuous Bcc: attacker@example.com"'
)
})
it('strips embedded CRLF from the attachment mimeType', () => {
const injected = 'text/plain\r\nBcc: attacker@example.com'
const message = buildMimeMessage({
to: 'a@example.com',
body: 'hello',
attachments: [
{
filename: 'note.txt',
mimeType: injected,
content: Buffer.from('hi'),
},
],
})
const lines = message.split('\n')
const bccLines = lines.filter((line) => line.startsWith('Bcc:'))
expect(bccLines).toHaveLength(0)
expect(message).toContain('Content-Type: text/plain Bcc: attacker@example.com')
})
it('preserves legitimate ASCII, Unicode, and multi-recipient values unchanged', () => {
const message = buildMimeMessage({
to: 'a@example.com, b@example.com',
cc: 'c@example.com',
subject: 'Café meeting 🎉',
body: 'hello',
attachments: [
{
filename: 'note.txt',
mimeType: 'text/plain',
content: Buffer.from('hi'),
},
],
})
expect(message).toContain('To: a@example.com, b@example.com')
expect(message).toContain('Cc: c@example.com')
expect(message).toContain(`Subject: ${encodeRfc2047('Café meeting 🎉')}`)
expect(message).toContain('Content-Disposition: attachment; filename="note.txt"')
})
})
+559
View File
@@ -0,0 +1,559 @@
import { generateRandomString } from '@sim/utils/random'
import { convert } from 'html-to-text'
import type {
GmailAttachment,
GmailMessage,
GmailReadParams,
GmailToolResponse,
} from '@/tools/gmail/types'
export const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'
/**
* Fetch original message headers for threading
* @param messageId Gmail message ID to fetch headers from
* @param accessToken Gmail access token
* @returns Object containing threading headers (messageId, references, subject)
*/
export async function fetchThreadingHeaders(
messageId: string,
accessToken: string
): Promise<{
messageId?: string
references?: string
subject?: string
}> {
try {
const messageResponse = await fetch(
`${GMAIL_API_BASE}/messages/${messageId}?format=metadata&metadataHeaders=Message-ID&metadataHeaders=References&metadataHeaders=Subject`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (messageResponse.ok) {
const messageData = await messageResponse.json()
const headers = messageData.payload?.headers || []
return {
messageId: headers.find((h: any) => h.name.toLowerCase() === 'message-id')?.value,
references: headers.find((h: any) => h.name.toLowerCase() === 'references')?.value,
subject: headers.find((h: any) => h.name.toLowerCase() === 'subject')?.value,
}
}
} catch (error) {
// Continue without threading headers rather than failing
}
return {}
}
// Helper function to process a Gmail message
export async function processMessage(
message: GmailMessage,
params?: GmailReadParams
): Promise<GmailToolResponse> {
// Check if message and payload exist
if (!message || !message.payload) {
return {
success: true,
output: {
content: 'Unable to process email: Invalid message format',
metadata: {
id: message?.id || '',
threadId: message?.threadId || '',
labelIds: message?.labelIds || [],
},
},
}
}
const headers = message.payload.headers || []
const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value || ''
const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value || ''
const to = headers.find((h) => h.name.toLowerCase() === 'to')?.value || ''
const date = headers.find((h) => h.name.toLowerCase() === 'date')?.value || ''
// Extract the message body
const body = extractMessageBody(message.payload)
// Check for attachments
const attachmentInfo = extractAttachmentInfo(message.payload)
const hasAttachments = attachmentInfo.length > 0
// Download attachments if requested
let attachments: GmailAttachment[] | undefined
if (params?.includeAttachments && hasAttachments && params.accessToken) {
try {
attachments = await downloadAttachments(message.id, attachmentInfo, params.accessToken)
} catch (error) {
// Continue without attachments rather than failing the entire request
}
}
const result: GmailToolResponse = {
success: true,
output: {
content: body || 'No content found in email',
metadata: {
id: message.id || '',
threadId: message.threadId || '',
labelIds: message.labelIds || [],
from,
to,
subject,
date,
hasAttachments,
attachmentCount: attachmentInfo.length,
},
// Always include attachments array (empty if none downloaded)
attachments: attachments || [],
},
}
return result
}
// Helper function to process a message for summary (without full content)
export function processMessageForSummary(message: GmailMessage): any {
if (!message || !message.payload) {
return {
id: message?.id || '',
threadId: message?.threadId || '',
subject: 'Unknown Subject',
from: 'Unknown Sender',
to: '',
date: '',
snippet: message?.snippet || '',
}
}
const headers = message.payload.headers || []
const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value || 'No Subject'
const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value || 'Unknown Sender'
const to = headers.find((h) => h.name.toLowerCase() === 'to')?.value || ''
const date = headers.find((h) => h.name.toLowerCase() === 'date')?.value || ''
return {
id: message.id,
threadId: message.threadId,
subject,
from,
to,
date,
snippet: message.snippet || '',
}
}
// Helper function to recursively extract message body from MIME parts
export function extractMessageBody(payload: any): string {
// If the payload has a body with data, decode it
if (payload.body?.data) {
return Buffer.from(payload.body.data, 'base64').toString()
}
// If there are no parts, return empty string
if (!payload.parts || !Array.isArray(payload.parts) || payload.parts.length === 0) {
return ''
}
// First try to find a text/plain part
const textPart = payload.parts.find((part: any) => part.mimeType === 'text/plain')
if (textPart?.body?.data) {
return Buffer.from(textPart.body.data, 'base64').toString()
}
// If no text/plain, try to find text/html
const htmlPart = payload.parts.find((part: any) => part.mimeType === 'text/html')
if (htmlPart?.body?.data) {
return Buffer.from(htmlPart.body.data, 'base64').toString()
}
// If we have multipart/alternative or other complex types, recursively check parts
for (const part of payload.parts) {
if (part.parts) {
const nestedBody = extractMessageBody(part)
if (nestedBody) {
return nestedBody
}
}
}
// If we couldn't find any text content, return empty string
return ''
}
// Helper function to extract attachment information from message payload
export function extractAttachmentInfo(
payload: any
): Array<{ attachmentId: string; filename: string; mimeType: string; size: number }> {
const attachments: Array<{
attachmentId: string
filename: string
mimeType: string
size: number
}> = []
function processPayloadPart(part: any) {
// Check if this part has an attachment
if (part.body?.attachmentId && part.filename) {
attachments.push({
attachmentId: part.body.attachmentId,
filename: part.filename,
mimeType: part.mimeType || 'application/octet-stream',
size: part.body.size || 0,
})
}
// Recursively process nested parts
if (part.parts && Array.isArray(part.parts)) {
part.parts.forEach(processPayloadPart)
}
}
// Process the main payload
processPayloadPart(payload)
return attachments
}
// Helper function to download attachments from Gmail API
export async function downloadAttachments(
messageId: string,
attachmentInfo: Array<{ attachmentId: string; filename: string; mimeType: string; size: number }>,
accessToken: string
): Promise<GmailAttachment[]> {
const downloadedAttachments: GmailAttachment[] = []
for (const attachment of attachmentInfo) {
try {
// Download attachment from Gmail API
const attachmentResponse = await fetch(
`${GMAIL_API_BASE}/messages/${messageId}/attachments/${attachment.attachmentId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
if (!attachmentResponse.ok) {
await attachmentResponse.body?.cancel().catch(() => {})
continue
}
const attachmentData = (await attachmentResponse.json()) as { data: string; size: number }
// Decode base64url data to buffer
// Gmail API returns data in base64url format (URL-safe base64)
const base64Data = attachmentData.data.replace(/-/g, '+').replace(/_/g, '/')
const buffer = Buffer.from(base64Data, 'base64')
downloadedAttachments.push({
name: attachment.filename,
data: buffer.toString('base64'),
mimeType: attachment.mimeType,
size: attachment.size,
})
} catch (error) {
// Continue with other attachments
}
}
return downloadedAttachments
}
// Helper function to create a summary of multiple messages
export function createMessagesSummary(messages: any[]): string {
if (messages.length === 0) {
return 'No messages found.'
}
let summary = `Found ${messages.length} messages:\n\n`
messages.forEach((msg, index) => {
summary += `${index + 1}. Subject: ${msg.subject}\n`
summary += ` From: ${msg.from}\n`
summary += ` To: ${msg.to}\n`
summary += ` Date: ${msg.date}\n`
summary += ` ID: ${msg.id}\n`
summary += ` Thread ID: ${msg.threadId}\n`
summary += ` Preview: ${msg.snippet}\n\n`
})
summary += `To read full content of a specific message, use the gmail_read tool with messageId: ${messages.map((m) => m.id).join(', ')}`
return summary
}
/**
* Generate a unique MIME boundary string
*/
function generateBoundary(): string {
return `----=_Part_${Date.now()}_${generateRandomString(13)}`
}
/**
* Encode a header value using RFC 2047 Base64 encoding if it contains non-ASCII characters.
* This matches Google's own Gmail API sample: `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`
* @see https://github.com/googleapis/google-api-nodejs-client/blob/main/samples/gmail/send.js
*/
export function encodeRfc2047(value: string): string {
// eslint-disable-next-line no-control-regex
if (/^[\x00-\x7F]*$/.test(value)) {
return value
}
return `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`
}
/**
* Strips CR/LF so a value can't introduce extra lines when placed into a MIME header.
*/
export function sanitizeHeaderValue(value: string): string {
return value.replace(/[\r\n]+/g, ' ')
}
/**
* Encode string or buffer to base64url format (URL-safe base64)
* Gmail API requires base64url encoding for the raw message field
*/
export function base64UrlEncode(data: string | Buffer): string {
const base64 = Buffer.from(data).toString('base64')
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Escape HTML special characters so user-supplied text renders safely inside an HTML body.
*/
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Convert a plain-text body to an HTML body that flows naturally in Gmail.
* Newlines become `<br>` rather than per-paragraph `<p>` tags or a CSS
* `white-space` rule — `<p>` margins are what Gmail's "Remove formatting"
* button strips, and `white-space` has inconsistent support across email
* clients (including Gmail for non-Google accounts). Plain `<br>` line
* breaks are the standard, client-agnostic way to preserve plain-text
* formatting in an HTML alternative part.
* This avoids the narrow hard-wrapped rendering Gmail uses for `text/plain`.
*/
export function plainTextToHtml(body: string): string {
const normalized = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const escaped = escapeHtml(normalized).replace(/\n/g, '<br>')
return `<!DOCTYPE html><html><body>${escaped}</body></html>`
}
/**
* Best-effort conversion of an HTML body to a plain-text fallback. Used so we
* always include a plain-text part alongside HTML for clients that don't render
* HTML. Delegates to the `html-to-text` library for robust tag stripping and
* entity decoding (also used elsewhere in the repo for the same purpose).
*/
export function htmlToPlainText(html: string): string {
return convert(html, {
wordwrap: false,
selectors: [
{ selector: 'a', options: { hideLinkHrefIfSameAsText: true, noAnchorUrl: true } },
{ selector: 'img', format: 'skip' },
{ selector: 'script', format: 'skip' },
{ selector: 'style', format: 'skip' },
],
})
}
/**
* Produce the plain-text and HTML representations of a body so we can emit a
* `multipart/alternative` section. Gmail renders the HTML version full-width
* (matching how a manually-composed email looks); the plain-text part is the
* fallback for clients that don't render HTML.
*/
export function buildBodyAlternatives(
body: string,
contentType: 'text' | 'html' | undefined
): { plain: string; html: string } {
if (contentType === 'html') {
return { plain: htmlToPlainText(body) || body, html: body }
}
return { plain: body, html: plainTextToHtml(body) }
}
/**
* Encode a text body as base64 with RFC 2045 line wrapping (max 76 chars).
* Using base64 lets us safely transport arbitrary UTF-8 (emoji, accented
* characters, etc.) — `7bit` is only valid for strict 7-bit ASCII.
*/
function encodeBodyBase64(content: string): string[] {
const base64 = Buffer.from(content, 'utf-8').toString('base64')
return base64.match(/.{1,76}/g) || ['']
}
/**
* Render the inner part of a `multipart/alternative` section (text/plain
* followed by text/html, per RFC 2046 — clients pick the last format they
* understand).
*/
function renderAlternativeParts(plain: string, html: string, boundary: string): string[] {
return [
`--${boundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'Content-Transfer-Encoding: base64',
'',
...encodeBodyBase64(plain),
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'Content-Transfer-Encoding: base64',
'',
...encodeBodyBase64(html),
'',
`--${boundary}--`,
]
}
/**
* Build a `multipart/alternative` email (without attachments). Always emits
* both text/plain and text/html parts so Gmail renders messages full-width
* like a hand-composed email.
* @returns Base64url encoded raw message
*/
export function buildSimpleEmailMessage(params: {
to: string
cc?: string | null
bcc?: string | null
subject?: string | null
body: string
contentType?: 'text' | 'html'
inReplyTo?: string
references?: string
}): string {
const { to, cc, bcc, subject, body, contentType, inReplyTo, references } = params
const boundary = generateBoundary()
const { plain, html } = buildBodyAlternatives(body, contentType)
const emailHeaders = ['MIME-Version: 1.0', `To: ${sanitizeHeaderValue(to)}`]
if (cc) {
emailHeaders.push(`Cc: ${sanitizeHeaderValue(cc)}`)
}
if (bcc) {
emailHeaders.push(`Bcc: ${sanitizeHeaderValue(bcc)}`)
}
emailHeaders.push(`Subject: ${encodeRfc2047(sanitizeHeaderValue(subject || ''))}`)
if (inReplyTo) {
const sanitizedInReplyTo = sanitizeHeaderValue(inReplyTo)
emailHeaders.push(`In-Reply-To: ${sanitizedInReplyTo}`)
const referencesChain = references
? `${sanitizeHeaderValue(references)} ${sanitizedInReplyTo}`
: sanitizedInReplyTo
emailHeaders.push(`References: ${referencesChain}`)
}
emailHeaders.push(`Content-Type: multipart/alternative; boundary="${boundary}"`)
emailHeaders.push('')
emailHeaders.push(...renderAlternativeParts(plain, html, boundary))
const email = emailHeaders.join('\n')
return Buffer.from(email).toString('base64url')
}
/**
* Build a MIME multipart message with optional attachments
* @param params Message parameters including recipients, subject, body, and attachments
* @returns Complete MIME message string ready to be base64url encoded
*/
export interface BuildMimeMessageParams {
to: string
cc?: string
bcc?: string
subject?: string
body: string
contentType?: 'text' | 'html'
inReplyTo?: string
references?: string
attachments?: Array<{
filename: string
mimeType: string
content: Buffer
}>
}
export function buildMimeMessage(params: BuildMimeMessageParams): string {
const { to, cc, bcc, subject, body, contentType, inReplyTo, references, attachments } = params
const messageParts: string[] = []
const { plain, html } = buildBodyAlternatives(body, contentType)
messageParts.push(`To: ${sanitizeHeaderValue(to)}`)
if (cc) {
messageParts.push(`Cc: ${sanitizeHeaderValue(cc)}`)
}
if (bcc) {
messageParts.push(`Bcc: ${sanitizeHeaderValue(bcc)}`)
}
messageParts.push(`Subject: ${encodeRfc2047(sanitizeHeaderValue(subject || ''))}`)
const sanitizedInReplyTo = inReplyTo ? sanitizeHeaderValue(inReplyTo) : undefined
const sanitizedReferences = references ? sanitizeHeaderValue(references) : undefined
if (sanitizedInReplyTo) {
messageParts.push(`In-Reply-To: ${sanitizedInReplyTo}`)
}
if (sanitizedReferences) {
const referencesChain = sanitizedInReplyTo
? `${sanitizedReferences} ${sanitizedInReplyTo}`
: sanitizedReferences
messageParts.push(`References: ${referencesChain}`)
} else if (sanitizedInReplyTo) {
messageParts.push(`References: ${sanitizedInReplyTo}`)
}
messageParts.push('MIME-Version: 1.0')
if (attachments && attachments.length > 0) {
const mixedBoundary = generateBoundary()
const altBoundary = generateBoundary()
messageParts.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`)
messageParts.push('')
messageParts.push(`--${mixedBoundary}`)
messageParts.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`)
messageParts.push('')
messageParts.push(...renderAlternativeParts(plain, html, altBoundary))
messageParts.push('')
for (const attachment of attachments) {
messageParts.push(`--${mixedBoundary}`)
messageParts.push(`Content-Type: ${sanitizeHeaderValue(attachment.mimeType)}`)
messageParts.push(
`Content-Disposition: attachment; filename="${sanitizeHeaderValue(attachment.filename)}"`
)
messageParts.push('Content-Transfer-Encoding: base64')
messageParts.push('')
const base64Content = attachment.content.toString('base64')
const lines = base64Content.match(/.{1,76}/g) || []
messageParts.push(...lines)
messageParts.push('')
}
messageParts.push(`--${mixedBoundary}--`)
} else {
const altBoundary = generateBoundary()
messageParts.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`)
messageParts.push('')
messageParts.push(...renderAlternativeParts(plain, html, altBoundary))
}
return messageParts.join('\n')
}