fix(sendgrid): fix active field coercion, add pagination, tighten output typing (#5368)

* fix(sendgrid): fix active field coercion, add pagination, tighten output typing

- Fix active field for create_template_version being sent as the string
  "true"/"false" instead of the SendGrid-required int 0/1
- Add missing authMode: ApiKey on SendGridBlock
- Add pageToken/nextPageToken pagination support to list_templates and
  list_all_lists (SendGrid page_token cursor, parsed from _metadata.next)
- Fix nullable output fields to use ?? null / ?? [] with optional: true
  across get_contact, search_contacts, remove_contacts_from_list,
  create_template_version, add_contact, send_mail
- Remove dead data.templates fallback in list_templates (API only
  ever returns result)
- Remove unused UpdateContactParams/UpdateListParams/UpdateTemplateParams
  dead types; make CreateTemplateParams.generation optional to match
  actual tool behavior

* fix(sendgrid): address Cursor Bugbot findings on pagination and active coercion

- Gate listPageToken/templatePageToken remap on operation so a stale
  token from the other list operation can't override the intended one
- Fix active coercion to also treat a real boolean true (from a dynamic
  <Block.output> reference) as active, not just the dropdown string 'true'

* fix(sendgrid): coerce active to int at the tool layer too

Per Greptile: the block-level active coercion only covered the UI
path. A direct sendgrid_create_template_version tool invocation with
a boolean active would still send a raw boolean to SendGrid. Coerce
to 0/1 in the tool's own request body so both paths are correct.

* fix(sendgrid): always send page_size on list_templates

SendGrid's GET /v3/templates requires page_size on every request
(no server-side default) — omitting it errors. Default to 20 to
match our own documented default when the caller doesn't set one.

* fix(sendgrid): explicit false/'false' check for active flag

Per Cursor Bugbot: params.active ? 1 : 0 treated any truthy string
(including "false") as active. Extracted a toActiveFlag helper that
only treats real false or the string 'false' as inactive, everything
else (including unset) defaults to active — matches the tool's
documented default.

* fix(sendgrid): handle numeric 0 in toActiveFlag

Per Greptile: the block coerces active to a number (0/1) before
calling the tool, but toActiveFlag only checked for false/'false',
so the block's inactive selection (0) fell through to the
"active" branch. Check against an explicit inactive-values set
covering the boolean, string, and numeric forms.

* fix(sendgrid): nest add_contact custom fields under custom_fields

Pre-existing bug (predates this PR): custom fields were merged onto
the contact object as top-level sibling keys via safeAssign/Object.assign,
but SendGrid's PUT /v3/marketing/contacts requires them nested under a
custom_fields object. SendGrid silently drops unrecognized top-level
keys, so the documented customFields param never actually reached
SendGrid. Caught during a final adversarial re-verification pass
before merge.

* fix(sendgrid): document consistent page_size requirement for list_templates pagination

Per Cursor Bugbot: list_templates always defaults page_size to 20
when unset (required by SendGrid), so a follow-up pageToken-only
call after a first call with a larger pageSize would silently
shrink to 20 and desync page boundaries. This is inherent to a
stateless tool call (SendGrid requires page_size on every request,
and the tool has no way to remember the prior call's value), so
clarify via param description and UI placeholder that callers must
repeat the same pageSize across paginated calls.

* chore(api-validation): bump stale route-count ratchet baseline 883->884

Unrelated to the SendGrid work in this branch. staging's own HEAD
already has 884 compliant Zod-backed API routes (0 non-Zod), but this
ratchet baseline was never bumped when that route landed, so any PR
rebasing onto current staging fails check:api-validation:strict with
"route count increased from 883 to 884". All routes remain fully
Zod-backed; this is a mechanical counter update, not a policy change.

* fix(sendgrid): dedupe active coercion between block and tool

Per Cursor Bugbot: the block's pre-coercion only recognized the
dropdown string 'true' or boolean true as active, so a dynamic
reference producing numeric 1 or string '1' fell through to 0 and
silently created an inactive template version. Exported the tool's
toActiveFlag and reused it in the block instead of duplicating the
inactive-value logic, so both layers can no longer drift out of sync.
This commit is contained in:
Waleed
2026-07-02 11:16:07 -07:00
committed by GitHub
parent 11c7a36f11
commit f6b802e7cb
10 changed files with 151 additions and 61 deletions
+32 -1
View File
@@ -1,7 +1,8 @@
import { SendgridIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import { toActiveFlag } from '@/tools/sendgrid/create_template_version'
import type { SendMailResult } from '@/tools/sendgrid/types'
export const SendGridBlock: BlockConfig<SendMailResult> = {
@@ -13,6 +14,7 @@ export const SendGridBlock: BlockConfig<SendMailResult> = {
docsLink: 'https://docs.sim.ai/integrations/sendgrid',
category: 'tools',
integrationType: IntegrationType.Email,
authMode: AuthMode.ApiKey,
bgColor: '#1A82E2',
icon: SendgridIcon,
@@ -387,6 +389,14 @@ Return ONLY the JSON array.`,
condition: { field: 'operation', value: 'list_all_lists' },
mode: 'advanced',
},
{
id: 'listPageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Page token from a previous response',
condition: { field: 'operation', value: 'list_all_lists' },
mode: 'advanced',
},
// Template fields
{
id: 'templateName',
@@ -434,6 +444,14 @@ Return ONLY the JSON array.`,
condition: { field: 'operation', value: 'list_templates' },
mode: 'advanced',
},
{
id: 'templatePageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Page token from a previous response (keep Page Size the same)',
condition: { field: 'operation', value: 'list_templates' },
mode: 'advanced',
},
{
id: 'versionName',
title: 'Version Name',
@@ -579,7 +597,10 @@ Return ONLY the HTML content.`,
templateGenerations,
listPageSize,
templatePageSize,
listPageToken,
templatePageToken,
attachments,
active,
...rest
} = params
@@ -599,7 +620,11 @@ Return ONLY the HTML content.`,
...(templateGenerations && { generations: templateGenerations }),
...(listPageSize && { pageSize: listPageSize }),
...(templatePageSize && { pageSize: templatePageSize }),
...(operation === 'list_all_lists' && listPageToken && { pageToken: listPageToken }),
...(operation === 'list_templates' &&
templatePageToken && { pageToken: templatePageToken }),
...(normalizedAttachments && { attachments: normalizedAttachments }),
...(active !== undefined && { active: toActiveFlag(active) }),
}
},
},
@@ -637,12 +662,14 @@ Return ONLY the HTML content.`,
listName: { type: 'string', description: 'List name' },
listId: { type: 'string', description: 'List ID' },
listPageSize: { type: 'number', description: 'Page size for listing lists' },
listPageToken: { type: 'string', description: 'Page token for listing lists' },
// Template inputs
templateName: { type: 'string', description: 'Template name' },
templateId: { type: 'string', description: 'Template ID' },
generation: { type: 'string', description: 'Template generation' },
templateGenerations: { type: 'string', description: 'Filter templates by generation' },
templatePageSize: { type: 'number', description: 'Page size for listing templates' },
templatePageToken: { type: 'string', description: 'Page token for listing templates' },
versionName: { type: 'string', description: 'Template version name' },
templateSubject: { type: 'string', description: 'Template subject' },
htmlContent: { type: 'string', description: 'HTML content' },
@@ -677,6 +704,10 @@ Return ONLY the HTML content.`,
templates: { type: 'json', description: 'Array of templates' },
generation: { type: 'string', description: 'Template generation' },
versions: { type: 'json', description: 'Array of template versions' },
nextPageToken: {
type: 'string',
description: 'Token for the next page of results (list_all_lists, list_templates)',
},
// Template version outputs
templateId: { type: 'string', description: 'Template ID' },
active: { type: 'boolean', description: 'Whether template version is active' },
+9 -6
View File
@@ -1,4 +1,3 @@
import { safeAssign } from '@/tools/safe-assign'
import type {
AddContactParams,
ContactResult,
@@ -73,7 +72,7 @@ export const sendGridAddContactTool: ToolConfig<AddContactParams, ContactResult>
typeof params.customFields === 'string'
? JSON.parse(params.customFields)
: params.customFields
safeAssign(contact, customFields as Record<string, unknown>)
contact.custom_fields = customFields as Record<string, unknown>
}
const body: SendGridContactRequest = {
@@ -99,7 +98,7 @@ export const sendGridAddContactTool: ToolConfig<AddContactParams, ContactResult>
return {
success: true,
output: {
jobId: data.job_id,
jobId: data.job_id ?? null,
email: params?.email || '',
firstName: params?.firstName,
lastName: params?.lastName,
@@ -110,10 +109,14 @@ export const sendGridAddContactTool: ToolConfig<AddContactParams, ContactResult>
},
outputs: {
jobId: { type: 'string', description: 'Job ID for tracking the async contact creation' },
jobId: {
type: 'string',
description: 'Job ID for tracking the async contact creation',
optional: true,
},
email: { type: 'string', description: 'Contact email address' },
firstName: { type: 'string', description: 'Contact first name' },
lastName: { type: 'string', description: 'Contact last name' },
firstName: { type: 'string', description: 'Contact first name', optional: true },
lastName: { type: 'string', description: 'Contact last name', optional: true },
message: { type: 'string', description: 'Status message' },
},
}
@@ -5,6 +5,16 @@ import type {
} from '@/tools/sendgrid/types'
import type { ToolConfig } from '@/tools/types'
const INACTIVE_VALUES: unknown[] = [false, 'false', 0, '0']
/** Coerces any dynamic-reference form of SendGrid's active flag (boolean, string, or
* number) to the 0/1 integer the API requires. Shared with the block's own
* pre-coercion in blocks/blocks/sendgrid.ts so both layers stay in sync. */
export function toActiveFlag(active: unknown): 0 | 1 {
if (active === undefined) return 1
return INACTIVE_VALUES.includes(active) ? 0 : 1
}
export const sendGridCreateTemplateVersionTool: ToolConfig<
CreateTemplateVersionParams,
TemplateVersionResult
@@ -70,7 +80,7 @@ export const sendGridCreateTemplateVersionTool: ToolConfig<
const body: SendGridTemplateVersionRequest = {
name: params.name,
subject: params.subject,
active: params.active !== undefined ? params.active : 1,
active: toActiveFlag(params.active),
}
if (params.htmlContent) {
@@ -101,9 +111,9 @@ export const sendGridCreateTemplateVersionTool: ToolConfig<
name: data.name,
subject: data.subject,
active: data.active === 1,
htmlContent: data.html_content,
plainContent: data.plain_content,
updatedAt: data.updated_at,
htmlContent: data.html_content ?? null,
plainContent: data.plain_content ?? null,
updatedAt: data.updated_at ?? null,
},
}
},
@@ -114,8 +124,8 @@ export const sendGridCreateTemplateVersionTool: ToolConfig<
name: { type: 'string', description: 'Version name' },
subject: { type: 'string', description: 'Email subject' },
active: { type: 'boolean', description: 'Whether this version is active' },
htmlContent: { type: 'string', description: 'HTML content' },
plainContent: { type: 'string', description: 'Plain text content' },
updatedAt: { type: 'string', description: 'Last update timestamp' },
htmlContent: { type: 'string', description: 'HTML content', optional: true },
plainContent: { type: 'string', description: 'Plain text content', optional: true },
updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
},
}
+12 -8
View File
@@ -47,8 +47,8 @@ export const sendGridGetContactTool: ToolConfig<GetContactParams, ContactResult>
lastName: data.last_name,
createdAt: data.created_at,
updatedAt: data.updated_at,
listIds: data.list_ids,
customFields: data.custom_fields,
listIds: data.list_ids ?? [],
customFields: data.custom_fields ?? null,
},
}
},
@@ -56,11 +56,15 @@ export const sendGridGetContactTool: ToolConfig<GetContactParams, ContactResult>
outputs: {
id: { type: 'string', description: 'Contact ID' },
email: { type: 'string', description: 'Contact email address' },
firstName: { type: 'string', description: 'Contact first name' },
lastName: { type: 'string', description: 'Contact last name' },
createdAt: { type: 'string', description: 'Creation timestamp' },
updatedAt: { type: 'string', description: 'Last update timestamp' },
listIds: { type: 'json', description: 'Array of list IDs the contact belongs to' },
customFields: { type: 'json', description: 'Custom field values' },
firstName: { type: 'string', description: 'Contact first name', optional: true },
lastName: { type: 'string', description: 'Contact last name', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
listIds: {
type: 'json',
description: 'Array of list IDs the contact belongs to',
optional: true,
},
customFields: { type: 'json', description: 'Custom field values', optional: true },
},
}
+29 -2
View File
@@ -18,7 +18,13 @@ export const sendGridListAllListsTool: ToolConfig<ListAllListsParams, ListsResul
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of lists to return per page (default: 100)',
description: 'Number of lists to return per page (default: 100, max: 1000)',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Page token from a previous response (nextPageToken) to fetch the next page',
},
},
@@ -28,6 +34,9 @@ export const sendGridListAllListsTool: ToolConfig<ListAllListsParams, ListsResul
if (params.pageSize) {
url.searchParams.append('page_size', params.pageSize.toString())
}
if (params.pageToken) {
url.searchParams.append('page_token', params.pageToken)
}
return url.toString()
},
method: 'GET',
@@ -42,17 +51,35 @@ export const sendGridListAllListsTool: ToolConfig<ListAllListsParams, ListsResul
throw new Error(error.errors?.[0]?.message || 'Failed to list all lists')
}
const data = (await response.json()) as { result?: SendGridList[] }
const data = (await response.json()) as {
result?: SendGridList[]
_metadata?: { next?: string }
}
let nextPageToken: string | null = null
if (data._metadata?.next) {
try {
nextPageToken = new URL(data._metadata.next).searchParams.get('page_token')
} catch {
nextPageToken = null
}
}
return {
success: true,
output: {
lists: data.result || [],
nextPageToken,
},
}
},
outputs: {
lists: { type: 'json', description: 'Array of lists' },
nextPageToken: {
type: 'string',
description: 'Token to pass as pageToken to fetch the next page, if more results exist',
optional: true,
},
},
}
+30 -5
View File
@@ -24,7 +24,16 @@ export const sendGridListTemplatesTool: ToolConfig<ListTemplatesParams, Template
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of templates to return per page (default: 20)',
description:
'Number of templates to return per page (default: 20, max: 200). ' +
'When paginating with pageToken, pass the same pageSize used on the first request ' +
'to keep page boundaries consistent.',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Page token from a previous response (nextPageToken) to fetch the next page',
},
},
@@ -34,8 +43,9 @@ export const sendGridListTemplatesTool: ToolConfig<ListTemplatesParams, Template
if (params.generations) {
url.searchParams.append('generations', params.generations)
}
if (params.pageSize) {
url.searchParams.append('page_size', params.pageSize.toString())
url.searchParams.append('page_size', (params.pageSize || 20).toString())
if (params.pageToken) {
url.searchParams.append('page_token', params.pageToken)
}
return url.toString()
},
@@ -53,18 +63,33 @@ export const sendGridListTemplatesTool: ToolConfig<ListTemplatesParams, Template
const data = (await response.json()) as {
result?: SendGridTemplate[]
templates?: SendGridTemplate[]
_metadata?: { next?: string }
}
let nextPageToken: string | null = null
if (data._metadata?.next) {
try {
nextPageToken = new URL(data._metadata.next).searchParams.get('page_token')
} catch {
nextPageToken = null
}
}
return {
success: true,
output: {
templates: data.result || data.templates || [],
templates: data.result || [],
nextPageToken,
},
}
},
outputs: {
templates: { type: 'json', description: 'Array of templates' },
nextPageToken: {
type: 'string',
description: 'Token to pass as pageToken to fetch the next page, if more results exist',
optional: true,
},
},
}
@@ -56,12 +56,12 @@ export const sendGridRemoveContactsFromListTool: ToolConfig<
return {
success: true,
output: {
jobId: data.job_id,
jobId: data.job_id ?? null,
},
}
},
outputs: {
jobId: { type: 'string', description: 'Job ID for the request' },
jobId: { type: 'string', description: 'Job ID for the request', optional: true },
},
}
+6 -2
View File
@@ -54,13 +54,17 @@ export const sendGridSearchContactsTool: ToolConfig<SearchContactsParams, Contac
success: true,
output: {
contacts: data.result || [],
contactCount: data.contact_count,
contactCount: data.contact_count ?? null,
},
}
},
outputs: {
contacts: { type: 'json', description: 'Array of matching contacts' },
contactCount: { type: 'number', description: 'Total number of contacts found' },
contactCount: {
type: 'number',
description: 'Total number of contacts found',
optional: true,
},
},
}
+1 -1
View File
@@ -149,7 +149,7 @@ export const sendGridSendMailTool: ToolConfig<SendMailParams, SendMailResult> =
outputs: {
success: { type: 'boolean', description: 'Whether the email was sent successfully' },
messageId: { type: 'string', description: 'SendGrid message ID' },
messageId: { type: 'string', description: 'SendGrid message ID', optional: true },
to: { type: 'string', description: 'Recipient email address' },
subject: { type: 'string', description: 'Email subject' },
},
+13 -27
View File
@@ -71,6 +71,7 @@ export interface SendGridContactObject {
email: string
first_name?: string
last_name?: string
custom_fields?: Record<string, unknown>
[key: string]: unknown
}
@@ -82,7 +83,7 @@ export interface SendGridContactRequest {
export interface SendGridTemplateVersionRequest {
name: string
subject: string
active: number | boolean
active: number
html_content?: string
plain_content?: string
}
@@ -127,15 +128,6 @@ export interface AddContactParams extends SendGridBaseParams {
listIds?: string // Comma-separated list IDs
}
interface UpdateContactParams extends SendGridBaseParams {
contactId?: string
email: string
firstName?: string
lastName?: string
customFields?: string // JSON string
listIds?: string // Comma-separated list IDs
}
export interface SearchContactsParams extends SendGridBaseParams {
query: string
}
@@ -151,14 +143,14 @@ export interface DeleteContactParams extends SendGridBaseParams {
export interface ContactResult extends ToolResponse {
output: {
id?: string
jobId?: string
jobId?: string | null
email: string
firstName?: string
lastName?: string
createdAt?: string
updatedAt?: string
listIds?: string[]
customFields?: Record<string, unknown>
customFields?: Record<string, unknown> | null
message?: string
}
}
@@ -166,7 +158,7 @@ export interface ContactResult extends ToolResponse {
export interface ContactsResult extends ToolResponse {
output: {
contacts: SendGridContact[]
contactCount?: number
contactCount: number | null
}
}
@@ -179,17 +171,13 @@ export interface GetListParams extends SendGridBaseParams {
listId: string
}
interface UpdateListParams extends SendGridBaseParams {
listId: string
name: string
}
export interface DeleteListParams extends SendGridBaseParams {
listId: string
}
export interface ListAllListsParams extends SendGridBaseParams {
pageSize?: number
pageToken?: string
}
export interface AddContactsToListParams extends SendGridBaseParams {
@@ -213,24 +201,20 @@ export interface ListResult extends ToolResponse {
export interface ListsResult extends ToolResponse {
output: {
lists: SendGridList[]
nextPageToken: string | null
}
}
// Template types
export interface CreateTemplateParams extends SendGridBaseParams {
name: string
generation: 'legacy' | 'dynamic'
generation?: 'legacy' | 'dynamic'
}
export interface GetTemplateParams extends SendGridBaseParams {
templateId: string
}
interface UpdateTemplateParams extends SendGridBaseParams {
templateId: string
name: string
}
export interface DeleteTemplateParams extends SendGridBaseParams {
templateId: string
}
@@ -238,6 +222,7 @@ export interface DeleteTemplateParams extends SendGridBaseParams {
export interface ListTemplatesParams extends SendGridBaseParams {
generations?: string // 'legacy' or 'dynamic' or both
pageSize?: number
pageToken?: string
}
export interface CreateTemplateVersionParams extends SendGridBaseParams {
@@ -262,6 +247,7 @@ export interface TemplateResult extends ToolResponse {
export interface TemplatesResult extends ToolResponse {
output: {
templates: SendGridTemplate[]
nextPageToken: string | null
}
}
@@ -272,8 +258,8 @@ export interface TemplateVersionResult extends ToolResponse {
name: string
subject: string
active: boolean
htmlContent?: string
plainContent?: string
updatedAt?: string
htmlContent: string | null
plainContent: string | null
updatedAt: string | null
}
}