improvement(sendblue): audit fixes for optional group numbers, seat_id, typing state/duration (#5300)

* improvement(sendblue): audit fixes — optional group numbers, seat_id, typing state/duration

* fix(sendblue): guard group recipients and typing state/duration before request

* fix(sendblue): omit empty numbers array from group message body

* test(sendblue): add webhook handler tests; trim group_id and normalize empty group_id to null

* fix(sendblue): trim and drop blank group recipients before target guard
This commit is contained in:
Waleed
2026-06-30 16:59:42 -07:00
committed by GitHub
parent bdaeb652ce
commit 604d03e40d
9 changed files with 266 additions and 26 deletions
@@ -55,6 +55,7 @@ Send an iMessage or SMS to a single recipient via Sendblue.
| `content` | string | No | Message text content. Either content or media_url must be provided. |
| `media_url` | string | No | URL of a media file to send. Either content or media_url must be provided. |
| `send_style` | string | No | iMessage expressive style \(e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam\). |
| `seat_id` | string | No | Seat \(user\) the message is attributed to. Accepts the seat UUID or Firebase Auth subject. |
| `status_callback` | string | No | Webhook URL that Sendblue will POST message status updates to. |
#### Output
@@ -85,11 +86,12 @@ Send an iMessage or SMS to a group of recipients via Sendblue.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `numbers` | array | Yes | Recipient phone numbers in E.164 format \(e.g., \["+19998887777", "+13334445555"\]\) |
| `numbers` | array | No | Recipient phone numbers in E.164 format \(e.g., \["+19998887777", "+13334445555"\]\). Optional when sending to an existing group via group_id. |
| `from_number` | string | Yes | One of your registered Sendblue phone numbers to send from, in E.164 format \(e.g., +18887776666\) |
| `content` | string | No | Message text content. Either content or media_url must be provided. |
| `media_url` | string | No | URL of a media file to send. Either content or media_url must be provided. |
| `send_style` | string | No | iMessage expressive style \(e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam\). |
| `seat_id` | string | No | Seat \(user\) the message is attributed to. Accepts the seat UUID or Firebase Auth subject. |
| `group_id` | string | No | Unique identifier of an existing group to send to. Omit to start a new group. |
| `status_callback` | string | No | Webhook URL that Sendblue will POST message status updates to. |
@@ -142,6 +144,8 @@ Display a typing indicator to a recipient (not supported in group chats).
| --------- | ---- | -------- | ----------- |
| `number` | string | Yes | Recipient's phone number in E.164 format \(e.g., +19998887777\) |
| `from_number` | string | No | Your Sendblue line number to send from, in E.164 format. |
| `state` | string | No | "start" \(default\) shows the indicator; "stop" ends an active indicator before max_duration_ms expires. |
| `max_duration_ms` | number | No | How long \(ms\) the indicator stays visible before auto-stopping. Defaults to 60000. Must be between 1 and 300000. |
#### Output
@@ -226,7 +230,7 @@ Trigger when an inbound iMessage or SMS is received in Sendblue
| `was_downgraded` | boolean | True if the recipient lacks iMessage support |
| `plan` | string | Account plan type |
| `message_type` | string | Message category \(e.g., message, group\) |
| `group_id` | string | Group identifier, empty for non-group messages |
| `group_id` | string | Group identifier, null for non-group messages |
| `participants` | array | Participant phone numbers for group messages |
| `send_style` | string | Expressive style if applied |
| `opted_out` | boolean | True if the recipient has opted out |
@@ -266,7 +270,7 @@ Trigger when an outbound message status changes (SENT, DELIVERED, ERROR) in Send
| `was_downgraded` | boolean | True if the recipient lacks iMessage support |
| `plan` | string | Account plan type |
| `message_type` | string | Message category \(e.g., message, group\) |
| `group_id` | string | Group identifier, empty for non-group messages |
| `group_id` | string | Group identifier, null for non-group messages |
| `participants` | array | Participant phone numbers for group messages |
| `send_style` | string | Expressive style if applied |
| `opted_out` | boolean | True if the recipient has opted out |
+60 -9
View File
@@ -107,9 +107,9 @@ export const SendblueBlock: BlockConfig = {
id: 'numbers',
title: 'Recipient Numbers',
type: 'long-input',
placeholder: 'One phone number per line, e.g.\n+19998887777\n+13334445555',
placeholder:
'One phone number per line, e.g.\n+19998887777\n+13334445555\n(optional when sending to an existing Group ID)',
condition: { field: 'operation', value: 'sendblue_send_group_message' },
required: { field: 'operation', value: 'sendblue_send_group_message' },
},
{
id: 'content',
@@ -152,6 +152,17 @@ export const SendblueBlock: BlockConfig = {
mode: 'advanced',
condition: { field: 'operation', value: 'sendblue_send_group_message' },
},
{
id: 'seat_id',
title: 'Seat ID',
type: 'short-input',
placeholder: 'Seat UUID or Firebase Auth subject to attribute the message to',
mode: 'advanced',
condition: {
field: 'operation',
value: ['sendblue_send_message', 'sendblue_send_group_message'],
},
},
{
id: 'status_callback',
title: 'Status Callback URL',
@@ -163,6 +174,26 @@ export const SendblueBlock: BlockConfig = {
value: ['sendblue_send_message', 'sendblue_send_group_message'],
},
},
{
id: 'typing_state',
title: 'Typing State',
type: 'dropdown',
options: [
{ label: 'Start', id: 'start' },
{ label: 'Stop', id: 'stop' },
],
value: () => 'start',
mode: 'advanced',
condition: { field: 'operation', value: 'sendblue_send_typing_indicator' },
},
{
id: 'max_duration_ms',
title: 'Max Duration (ms)',
type: 'short-input',
placeholder: '60000 (1–300000)',
mode: 'advanced',
condition: { field: 'operation', value: 'sendblue_send_typing_indicator' },
},
{
id: 'message_id',
title: 'Message Handle / ID',
@@ -200,25 +231,32 @@ export const SendblueBlock: BlockConfig = {
content: params.content || undefined,
media_url: params.media_url || undefined,
send_style: params.send_style || undefined,
seat_id: params.seat_id || undefined,
status_callback: params.status_callback || undefined,
}
case 'sendblue_send_group_message':
case 'sendblue_send_group_message': {
const parsedNumbers =
typeof params.numbers === 'string'
? params.numbers
.split('\n')
.map((n: string) => n.trim())
.filter(Boolean)
: params.numbers
return {
...base,
numbers:
typeof params.numbers === 'string'
? params.numbers
.split('\n')
.map((n: string) => n.trim())
.filter(Boolean)
: params.numbers,
Array.isArray(parsedNumbers) && parsedNumbers.length === 0
? undefined
: parsedNumbers,
from_number: params.from_number,
content: params.content || undefined,
media_url: params.media_url || undefined,
send_style: params.send_style || undefined,
seat_id: params.seat_id || undefined,
group_id: params.group_id || undefined,
status_callback: params.status_callback || undefined,
}
}
case 'sendblue_evaluate_service':
return { ...base, number: params.number }
case 'sendblue_send_typing_indicator':
@@ -226,6 +264,13 @@ export const SendblueBlock: BlockConfig = {
...base,
number: params.number,
from_number: params.from_number || undefined,
state: params.typing_state || undefined,
max_duration_ms:
params.max_duration_ms !== undefined &&
params.max_duration_ms !== '' &&
Number.isFinite(Number(params.max_duration_ms))
? Number(params.max_duration_ms)
: undefined,
}
case 'sendblue_get_message':
return { ...base, message_id: params.message_id }
@@ -247,7 +292,13 @@ export const SendblueBlock: BlockConfig = {
media_url: { type: 'string', description: 'URL of media to send' },
send_style: { type: 'string', description: 'iMessage expressive style' },
group_id: { type: 'string', description: 'Existing group ID' },
seat_id: { type: 'string', description: 'Seat (user) the message is attributed to' },
status_callback: { type: 'string', description: 'Status callback webhook URL' },
typing_state: { type: 'string', description: 'Typing indicator state (start or stop)' },
max_duration_ms: {
type: 'number',
description: 'Typing indicator max visible duration in milliseconds',
},
message_id: { type: 'string', description: 'Message handle/ID to retrieve' },
},
@@ -0,0 +1,115 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { sendblueHandler } from '@/lib/webhooks/providers/sendblue'
const inboundBody = {
accountEmail: 'me@example.com',
content: 'hello',
media_url: '',
is_outbound: false,
status: 'RECEIVED',
message_handle: 'handle-123',
from_number: '+19998887777',
number: '+18887776666',
group_id: '',
}
const outboundBody = {
...inboundBody,
is_outbound: true,
status: 'SENT',
}
describe('sendblueHandler', () => {
describe('matchEvent', () => {
it('matches an inbound message for the message_received trigger', () => {
expect(
sendblueHandler.matchEvent!({
body: inboundBody,
webhook: { providerConfig: { triggerId: 'sendblue_message_received' } },
requestId: 'r1',
} as any)
).toBe(true)
})
it('rejects an outbound event for the message_received trigger', () => {
expect(
sendblueHandler.matchEvent!({
body: outboundBody,
webhook: { providerConfig: { triggerId: 'sendblue_message_received' } },
requestId: 'r1',
} as any)
).toBe(false)
})
it('matches an outbound status update for the message_status_updated trigger', () => {
expect(
sendblueHandler.matchEvent!({
body: outboundBody,
webhook: { providerConfig: { triggerId: 'sendblue_message_status_updated' } },
requestId: 'r1',
} as any)
).toBe(true)
})
it('passes through when the triggerId is unknown or unset', () => {
expect(
sendblueHandler.matchEvent!({
body: inboundBody,
webhook: {},
requestId: 'r1',
} as any)
).toBe(true)
})
it('rejects a non-object payload for a known trigger', () => {
expect(
sendblueHandler.matchEvent!({
body: 'not-an-object',
webhook: { providerConfig: { triggerId: 'sendblue_message_received' } },
requestId: 'r1',
} as any)
).toBe(false)
})
})
describe('extractIdempotencyId', () => {
it('uses the message handle alone when no status is present', () => {
expect(sendblueHandler.extractIdempotencyId!({ message_handle: 'handle-123' })).toBe(
'handle-123'
)
})
it('suffixes the status so SENT and DELIVERED on one handle stay distinct', () => {
expect(
sendblueHandler.extractIdempotencyId!({ message_handle: 'handle-123', status: 'DELIVERED' })
).toBe('handle-123:DELIVERED')
})
it('returns null when no message handle is present', () => {
expect(sendblueHandler.extractIdempotencyId!({})).toBeNull()
expect(sendblueHandler.extractIdempotencyId!('nope')).toBeNull()
})
})
describe('formatInput', () => {
it('returns the payload under input with empty strings normalized to null', async () => {
const result = await sendblueHandler.formatInput!({ body: inboundBody } as any)
expect(result.input.account_email).toBe('me@example.com')
expect(result.input.media_url).toBeNull()
expect(result.input.group_id).toBeNull()
expect(result.input.is_outbound).toBe(false)
expect(result.input.participants).toEqual([])
expect(result.input.raw).toBe(JSON.stringify(inboundBody))
})
it('defaults missing fields to null and tolerates a non-object body', async () => {
const result = await sendblueHandler.formatInput!({ body: undefined } as any)
expect(result.input.message_handle).toBeNull()
expect(result.input.content).toBeNull()
expect(result.input.participants).toEqual([])
})
})
})
+14 -2
View File
@@ -22,6 +22,18 @@ const SENDBLUE_TRIGGER_IS_OUTBOUND: Record<string, boolean> = {
sendblue_message_status_updated: true,
}
/**
* Sendblue webhook handler.
*
* No `verifyAuth` is implemented: Sendblue supports an optional per-webhook
* `secret`/`globalSecret` that it "includes in the webhook request headers,"
* but the official docs never name the header or specify whether the value is
* a plain token echo or an HMAC signature. Implementing verification today
* would require guessing the header name, so it is deferred. When Sendblue
* documents the scheme, wire `verifyTokenAuth` (plain token) or
* `createHmacVerifier` (HMAC) from `@/lib/webhooks/providers/utils` and add a
* secret sub-block to the block definition.
*/
export const sendblueHandler: WebhookProviderHandler = {
matchEvent({ body, webhook, requestId }: EventMatchContext): boolean {
const providerConfig = getProviderConfig(webhook)
@@ -60,7 +72,7 @@ export const sendblueHandler: WebhookProviderHandler = {
input: {
account_email: b.accountEmail ?? b.account_email ?? null,
content: b.content ?? null,
media_url: b.media_url ?? null,
media_url: (typeof b.media_url === 'string' && b.media_url) || null,
is_outbound: b.is_outbound ?? null,
status: b.status ?? null,
error_code: b.error_code ?? null,
@@ -76,7 +88,7 @@ export const sendblueHandler: WebhookProviderHandler = {
was_downgraded: b.was_downgraded ?? null,
plan: b.plan ?? null,
message_type: b.message_type ?? null,
group_id: b.group_id ?? null,
group_id: (typeof b.group_id === 'string' && b.group_id) || null,
participants: b.participants ?? [],
send_style: b.send_style ?? null,
opted_out: b.opted_out ?? null,
+26 -7
View File
@@ -23,10 +23,10 @@ export const sendblueSendGroupMessageTool: ToolConfig<
...sendblueBaseParamFields,
numbers: {
type: 'array',
required: true,
required: false,
visibility: 'user-or-llm',
description:
'Recipient phone numbers in E.164 format (e.g., ["+19998887777", "+13334445555"])',
'Recipient phone numbers in E.164 format (e.g., ["+19998887777", "+13334445555"]). Optional when sending to an existing group via group_id.',
items: { type: 'string', description: 'Phone number in E.164 format' },
},
from_number: {
@@ -55,6 +55,13 @@ export const sendblueSendGroupMessageTool: ToolConfig<
description:
'iMessage expressive style (e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam).',
},
seat_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Seat (user) the message is attributed to. Accepts the seat UUID or Firebase Auth subject.',
},
group_id: {
type: 'string',
required: false,
@@ -73,16 +80,28 @@ export const sendblueSendGroupMessageTool: ToolConfig<
url: `${SENDBLUE_API_BASE_URL}/api/send-group-message`,
method: 'POST',
headers: (params) => sendblueHeaders(params),
body: (params) =>
filterUndefined({
numbers: params.numbers,
body: (params) => {
const numbers = Array.isArray(params.numbers)
? params.numbers.map((n) => n.trim()).filter(Boolean)
: undefined
const hasNumbers = numbers !== undefined && numbers.length > 0
const hasGroupId = typeof params.group_id === 'string' && params.group_id.trim().length > 0
if (!hasNumbers && !hasGroupId) {
throw new Error(
'Provide either "numbers" to start a new group or "group_id" to message an existing group.'
)
}
return filterUndefined({
numbers: hasNumbers ? numbers : undefined,
from_number: params.from_number,
content: params.content,
media_url: params.media_url,
send_style: params.send_style,
group_id: params.group_id,
seat_id: params.seat_id,
group_id: hasGroupId ? params.group_id?.trim() : undefined,
status_callback: params.status_callback,
}),
})
},
},
transformResponse: async (response) => {
+8
View File
@@ -50,6 +50,13 @@ export const sendblueSendMessageTool: ToolConfig<
description:
'iMessage expressive style (e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam).',
},
seat_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Seat (user) the message is attributed to. Accepts the seat UUID or Firebase Auth subject.',
},
status_callback: {
type: 'string',
required: false,
@@ -69,6 +76,7 @@ export const sendblueSendMessageTool: ToolConfig<
content: params.content,
media_url: params.media_url,
send_style: params.send_style,
seat_id: params.seat_id,
status_callback: params.status_callback,
}),
},
@@ -33,17 +33,44 @@ export const sendblueSendTypingIndicatorTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Your Sendblue line number to send from, in E.164 format.',
},
state: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'"start" (default) shows the indicator; "stop" ends an active indicator before max_duration_ms expires.',
},
max_duration_ms: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'How long (ms) the indicator stays visible before auto-stopping. Defaults to 60000. Must be between 1 and 300000.',
},
},
request: {
url: `${SENDBLUE_API_BASE_URL}/api/send-typing-indicator`,
method: 'POST',
headers: (params) => sendblueHeaders(params),
body: (params) =>
filterUndefined({
body: (params) => {
if (params.state !== undefined && params.state !== 'start' && params.state !== 'stop') {
throw new Error('"state" must be either "start" or "stop".')
}
let maxDurationMs: number | undefined
if (params.max_duration_ms !== undefined) {
maxDurationMs = Number(params.max_duration_ms)
if (!Number.isInteger(maxDurationMs) || maxDurationMs < 1 || maxDurationMs > 300000) {
throw new Error('"max_duration_ms" must be an integer between 1 and 300000.')
}
}
return filterUndefined({
number: params.number,
from_number: params.from_number,
}),
state: params.state,
max_duration_ms: maxDurationMs,
})
},
},
transformResponse: async (response) => {
+5 -1
View File
@@ -29,15 +29,17 @@ export interface SendblueSendMessageParams extends SendblueBaseParams {
content?: string
media_url?: string
send_style?: SendblueSendStyle
seat_id?: string
status_callback?: string
}
export interface SendblueSendGroupMessageParams extends SendblueBaseParams {
numbers: string[]
numbers?: string[]
from_number: string
content?: string
media_url?: string
send_style?: SendblueSendStyle
seat_id?: string
group_id?: string
status_callback?: string
}
@@ -49,6 +51,8 @@ export interface SendblueEvaluateServiceParams extends SendblueBaseParams {
export interface SendblueTypingIndicatorParams extends SendblueBaseParams {
number: string
from_number?: string
state?: 'start' | 'stop'
max_duration_ms?: number
}
export interface SendblueGetMessageParams extends SendblueBaseParams {
+1 -1
View File
@@ -56,7 +56,7 @@ export function buildSendblueOutputs(): Record<string, TriggerOutput> {
},
plan: { type: 'string', description: 'Account plan type' },
message_type: { type: 'string', description: 'Message category (e.g., message, group)' },
group_id: { type: 'string', description: 'Group identifier, empty for non-group messages' },
group_id: { type: 'string', description: 'Group identifier, null for non-group messages' },
participants: { type: 'array', description: 'Participant phone numbers for group messages' },
send_style: { type: 'string', description: 'Expressive style if applied' },
opted_out: { type: 'boolean', description: 'True if the recipient has opted out' },