Skip to content

Commit 8b6307a

Browse files
authored
feat(gmail): add edit draft and update label tools (#4374)
* feat(gmail): add edit draft and update label tools * fix(gmail): correct legacy block access list and docs heading for edit_draft_v2 * fix(gmail): use shared contract for edit-draft route * regen docs * fix(knowledge): inline reranker model list in description for doc generator * resolve
1 parent be9c959 commit 8b6307a

13 files changed

Lines changed: 564 additions & 484 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/docs/content/docs/en/tools/outlook.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ Send emails using Outlook
5656
| `body` | string | Yes | Email body content |
5757
| `contentType` | string | No | Content type for the email body \(text or html\) |
5858
| `replyToMessageId` | string | No | Message ID to reply to \(for threading\) |
59-
| `conversationId` | string | No | Conversation ID for threading |
6059
| `cc` | string | No | CC recipients \(comma-separated\) |
6160
| `bcc` | string | No | BCC recipients \(comma-separated\) |
6261
| `attachments` | file[] | No | Files to attach to the email |

apps/docs/content/docs/en/triggers/linear.mdx

Lines changed: 0 additions & 466 deletions
Large diffs are not rendered by default.

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: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { gmailEditDraftContract } from '@/lib/api/contracts/google-tools'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { checkInternalAuth } from '@/lib/auth/hybrid'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
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+
export const POST = withRouteHandler(async (request: NextRequest) => {
23+
const requestId = generateRequestId()
24+
25+
try {
26+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
27+
28+
if (!authResult.success) {
29+
logger.warn(`[${requestId}] Unauthorized Gmail edit draft attempt: ${authResult.error}`)
30+
return NextResponse.json(
31+
{
32+
success: false,
33+
error: authResult.error || 'Authentication required',
34+
},
35+
{ status: 401 }
36+
)
37+
}
38+
39+
logger.info(
40+
`[${requestId}] Authenticated Gmail edit draft request via ${authResult.authType}`,
41+
{ userId: authResult.userId }
42+
)
43+
44+
const parsed = await parseRequest(gmailEditDraftContract, request, {})
45+
if (!parsed.success) return parsed.response
46+
const validatedData = parsed.data.body
47+
48+
logger.info(`[${requestId}] Updating Gmail draft`, {
49+
draftId: validatedData.draftId,
50+
to: validatedData.to,
51+
hasAttachments: !!(validatedData.attachments && validatedData.attachments.length > 0),
52+
attachmentCount: validatedData.attachments?.length || 0,
53+
})
54+
55+
const threadingHeaders = validatedData.replyToMessageId
56+
? await fetchThreadingHeaders(validatedData.replyToMessageId, validatedData.accessToken)
57+
: {}
58+
59+
const originalMessageId = threadingHeaders.messageId
60+
const originalReferences = threadingHeaders.references
61+
const originalSubject = threadingHeaders.subject
62+
63+
let rawMessage: string | undefined
64+
65+
if (validatedData.attachments && validatedData.attachments.length > 0) {
66+
const rawAttachments = validatedData.attachments
67+
const attachments = processFilesToUserFiles(rawAttachments, requestId, logger)
68+
69+
if (attachments.length > 0) {
70+
const totalSize = attachments.reduce((sum, file) => sum + file.size, 0)
71+
const maxSize = 25 * 1024 * 1024
72+
73+
if (totalSize > maxSize) {
74+
const sizeMB = (totalSize / (1024 * 1024)).toFixed(2)
75+
return NextResponse.json(
76+
{
77+
success: false,
78+
error: `Total attachment size (${sizeMB}MB) exceeds Gmail's limit of 25MB`,
79+
},
80+
{ status: 400 }
81+
)
82+
}
83+
84+
const attachmentBuffers = await Promise.all(
85+
attachments.map(async (file) => {
86+
const buffer = await downloadFileFromStorage(file, requestId, logger)
87+
return {
88+
filename: file.name,
89+
mimeType: file.type || 'application/octet-stream',
90+
content: buffer,
91+
}
92+
})
93+
)
94+
95+
const mimeMessage = buildMimeMessage({
96+
to: validatedData.to,
97+
cc: validatedData.cc ?? undefined,
98+
bcc: validatedData.bcc ?? undefined,
99+
subject: validatedData.subject || originalSubject || '',
100+
body: validatedData.body,
101+
contentType: validatedData.contentType || 'text',
102+
inReplyTo: originalMessageId,
103+
references: originalReferences,
104+
attachments: attachmentBuffers,
105+
})
106+
107+
rawMessage = base64UrlEncode(mimeMessage)
108+
}
109+
}
110+
111+
if (!rawMessage) {
112+
rawMessage = buildSimpleEmailMessage({
113+
to: validatedData.to,
114+
cc: validatedData.cc,
115+
bcc: validatedData.bcc,
116+
subject: validatedData.subject || originalSubject,
117+
body: validatedData.body,
118+
contentType: validatedData.contentType || 'text',
119+
inReplyTo: originalMessageId,
120+
references: originalReferences,
121+
})
122+
}
123+
124+
const draftMessage: { raw: string; threadId?: string } = { raw: rawMessage }
125+
if (validatedData.threadId) {
126+
draftMessage.threadId = validatedData.threadId
127+
}
128+
129+
const gmailResponse = await fetch(
130+
`${GMAIL_API_BASE}/drafts/${encodeURIComponent(validatedData.draftId)}`,
131+
{
132+
method: 'PUT',
133+
headers: {
134+
Authorization: `Bearer ${validatedData.accessToken}`,
135+
'Content-Type': 'application/json',
136+
},
137+
body: JSON.stringify({
138+
id: validatedData.draftId,
139+
message: draftMessage,
140+
}),
141+
}
142+
)
143+
144+
if (!gmailResponse.ok) {
145+
const errorText = await gmailResponse.text()
146+
logger.error(`[${requestId}] Gmail API error:`, errorText)
147+
return NextResponse.json(
148+
{
149+
success: false,
150+
error: `Gmail API error: ${gmailResponse.statusText}`,
151+
},
152+
{ status: gmailResponse.status }
153+
)
154+
}
155+
156+
const data = await gmailResponse.json()
157+
158+
logger.info(`[${requestId}] Draft updated successfully`, { draftId: data.id })
159+
160+
return NextResponse.json({
161+
success: true,
162+
output: {
163+
draftId: data.id ?? null,
164+
messageId: data.message?.id ?? null,
165+
threadId: data.message?.threadId ?? null,
166+
labelIds: data.message?.labelIds ?? null,
167+
},
168+
})
169+
} catch (error) {
170+
logger.error(`[${requestId}] Error updating Gmail draft:`, error)
171+
172+
return NextResponse.json(
173+
{
174+
success: false,
175+
error: error instanceof Error ? error.message : 'Internal server error',
176+
},
177+
{ status: 500 }
178+
)
179+
}
180+
})

0 commit comments

Comments
 (0)