Skip to content

Commit b4b3a13

Browse files
committed
feat(gmail): add edit draft and update label tools
1 parent 6080489 commit b4b3a13

8 files changed

Lines changed: 563 additions & 13 deletions

File tree

apps/docs/content/docs/en/tools/gmail.mdx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,34 @@ Draft emails using Gmail. Returns API-aligned fields only.
9090
| `threadId` | string | Gmail thread ID |
9191
| `labelIds` | array | Email labels |
9292

93+
### `gmail_edit_draft`
94+
95+
Update an existing Gmail draft in place without deleting and recreating it.
96+
97+
#### Input
98+
99+
| Parameter | Type | Required | Description |
100+
| --------- | ---- | -------- | ----------- |
101+
| `draftId` | string | Yes | ID of the draft to update \(from Gmail List Drafts or Gmail Get Draft\) |
102+
| `to` | string | Yes | Recipient email address |
103+
| `subject` | string | No | Email subject |
104+
| `body` | string | Yes | Email body content |
105+
| `contentType` | string | No | Content type for the email body \(text or html\) |
106+
| `threadId` | string | No | Thread ID to associate the draft with \(for threading\) |
107+
| `replyToMessageId` | string | No | Gmail message ID to reply to - use the "id" field from Gmail Read results \(not the RFC "messageId"\) |
108+
| `cc` | string | No | CC recipients \(comma-separated\) |
109+
| `bcc` | string | No | BCC recipients \(comma-separated\) |
110+
| `attachments` | file[] | No | Files to attach to the email draft |
111+
112+
#### Output
113+
114+
| Parameter | Type | Description |
115+
| --------- | ---- | ----------- |
116+
| `draftId` | string | Draft ID |
117+
| `messageId` | string | Gmail message ID for the draft |
118+
| `threadId` | string | Gmail thread ID |
119+
| `labelIds` | array | Email labels |
120+
93121
### `gmail_read`
94122

95123
Read emails from Gmail. Returns API-aligned fields only.

apps/sim/app/(landing)/integrations/data/integrations.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4662,6 +4662,10 @@
46624662
"name": "Draft Email",
46634663
"description": "Draft emails using Gmail"
46644664
},
4665+
{
4666+
"name": "Edit Draft",
4667+
"description": "Update an existing Gmail draft in place without deleting and recreating it."
4668+
},
46654669
{
46664670
"name": "Search Email",
46674671
"description": "Search emails in Gmail"
@@ -4699,7 +4703,7 @@
46994703
"description": "Remove label(s) from a Gmail message"
47004704
}
47014705
],
4702-
"operationCount": 12,
4706+
"operationCount": 13,
47034707
"triggers": [
47044708
{
47054709
"id": "gmail_poller",
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
import { checkInternalAuth } from '@/lib/auth/hybrid'
5+
import { generateRequestId } from '@/lib/core/utils/request'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
8+
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
9+
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
10+
import {
11+
base64UrlEncode,
12+
buildMimeMessage,
13+
buildSimpleEmailMessage,
14+
fetchThreadingHeaders,
15+
GMAIL_API_BASE,
16+
} from '@/tools/gmail/utils'
17+
18+
export const dynamic = 'force-dynamic'
19+
20+
const logger = createLogger('GmailEditDraftAPI')
21+
22+
const GmailEditDraftSchema = z.object({
23+
accessToken: z.string().min(1, 'Access token is required'),
24+
draftId: z.string().min(1, 'Draft ID is required'),
25+
to: z.string().min(1, 'Recipient email is required'),
26+
subject: z.string().optional().nullable(),
27+
body: z.string().min(1, 'Email body is required'),
28+
contentType: z.enum(['text', 'html']).optional().nullable(),
29+
threadId: z.string().optional().nullable(),
30+
replyToMessageId: z.string().optional().nullable(),
31+
cc: z.string().optional().nullable(),
32+
bcc: z.string().optional().nullable(),
33+
attachments: RawFileInputArraySchema.optional().nullable(),
34+
})
35+
36+
export const POST = withRouteHandler(async (request: NextRequest) => {
37+
const requestId = generateRequestId()
38+
39+
try {
40+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
41+
42+
if (!authResult.success) {
43+
logger.warn(`[${requestId}] Unauthorized Gmail edit draft attempt: ${authResult.error}`)
44+
return NextResponse.json(
45+
{
46+
success: false,
47+
error: authResult.error || 'Authentication required',
48+
},
49+
{ status: 401 }
50+
)
51+
}
52+
53+
logger.info(
54+
`[${requestId}] Authenticated Gmail edit draft request via ${authResult.authType}`,
55+
{ userId: authResult.userId }
56+
)
57+
58+
const body = await request.json()
59+
const validatedData = GmailEditDraftSchema.parse(body)
60+
61+
logger.info(`[${requestId}] Updating Gmail draft`, {
62+
draftId: validatedData.draftId,
63+
to: validatedData.to,
64+
hasAttachments: !!(validatedData.attachments && validatedData.attachments.length > 0),
65+
attachmentCount: validatedData.attachments?.length || 0,
66+
})
67+
68+
const threadingHeaders = validatedData.replyToMessageId
69+
? await fetchThreadingHeaders(validatedData.replyToMessageId, validatedData.accessToken)
70+
: {}
71+
72+
const originalMessageId = threadingHeaders.messageId
73+
const originalReferences = threadingHeaders.references
74+
const originalSubject = threadingHeaders.subject
75+
76+
let rawMessage: string | undefined
77+
78+
if (validatedData.attachments && validatedData.attachments.length > 0) {
79+
const rawAttachments = validatedData.attachments
80+
const attachments = processFilesToUserFiles(rawAttachments, requestId, logger)
81+
82+
if (attachments.length > 0) {
83+
const totalSize = attachments.reduce((sum, file) => sum + file.size, 0)
84+
const maxSize = 25 * 1024 * 1024
85+
86+
if (totalSize > maxSize) {
87+
const sizeMB = (totalSize / (1024 * 1024)).toFixed(2)
88+
return NextResponse.json(
89+
{
90+
success: false,
91+
error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`,
92+
},
93+
{ status: 400 }
94+
)
95+
}
96+
97+
const attachmentBuffers = await Promise.all(
98+
attachments.map(async (file) => {
99+
const buffer = await downloadFileFromStorage(file, requestId, logger)
100+
return {
101+
filename: file.name,
102+
mimeType: file.type || 'application/octet-stream',
103+
content: buffer,
104+
}
105+
})
106+
)
107+
108+
const mimeMessage = buildMimeMessage({
109+
to: validatedData.to,
110+
cc: validatedData.cc ?? undefined,
111+
bcc: validatedData.bcc ?? undefined,
112+
subject: validatedData.subject || originalSubject || '',
113+
body: validatedData.body,
114+
contentType: validatedData.contentType || 'text',
115+
inReplyTo: originalMessageId,
116+
references: originalReferences,
117+
attachments: attachmentBuffers,
118+
})
119+
120+
rawMessage = base64UrlEncode(mimeMessage)
121+
}
122+
}
123+
124+
if (!rawMessage) {
125+
rawMessage = buildSimpleEmailMessage({
126+
to: validatedData.to,
127+
cc: validatedData.cc,
128+
bcc: validatedData.bcc,
129+
subject: validatedData.subject || originalSubject,
130+
body: validatedData.body,
131+
contentType: validatedData.contentType || 'text',
132+
inReplyTo: originalMessageId,
133+
references: originalReferences,
134+
})
135+
}
136+
137+
const draftMessage: { raw: string; threadId?: string } = { raw: rawMessage }
138+
if (validatedData.threadId) {
139+
draftMessage.threadId = validatedData.threadId
140+
}
141+
142+
const gmailResponse = await fetch(
143+
`${GMAIL_API_BASE}/drafts/${encodeURIComponent(validatedData.draftId)}`,
144+
{
145+
method: 'PUT',
146+
headers: {
147+
Authorization: `Bearer ${validatedData.accessToken}`,
148+
'Content-Type': 'application/json',
149+
},
150+
body: JSON.stringify({
151+
id: validatedData.draftId,
152+
message: draftMessage,
153+
}),
154+
}
155+
)
156+
157+
if (!gmailResponse.ok) {
158+
const errorText = await gmailResponse.text()
159+
logger.error(`[${requestId}] Gmail API error:`, errorText)
160+
return NextResponse.json(
161+
{
162+
success: false,
163+
error: `Gmail API error: ${gmailResponse.statusText}`,
164+
},
165+
{ status: gmailResponse.status }
166+
)
167+
}
168+
169+
const data = await gmailResponse.json()
170+
171+
logger.info(`[${requestId}] Draft updated successfully`, { draftId: data.id })
172+
173+
return NextResponse.json({
174+
success: true,
175+
output: {
176+
draftId: data.id ?? null,
177+
messageId: data.message?.id ?? null,
178+
threadId: data.message?.threadId ?? null,
179+
labelIds: data.message?.labelIds ?? null,
180+
},
181+
})
182+
} catch (error) {
183+
if (error instanceof z.ZodError) {
184+
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
185+
return NextResponse.json(
186+
{
187+
success: false,
188+
error: 'Invalid request data',
189+
details: error.errors,
190+
},
191+
{ status: 400 }
192+
)
193+
}
194+
195+
logger.error(`[${requestId}] Error updating Gmail draft:`, error)
196+
197+
return NextResponse.json(
198+
{
199+
success: false,
200+
error: error instanceof Error ? error.message : 'Internal server error',
201+
},
202+
{ status: 500 }
203+
)
204+
}
205+
})

0 commit comments

Comments
 (0)