Skip to content

Commit 604d03e

Browse files
authored
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
1 parent bdaeb65 commit 604d03e

9 files changed

Lines changed: 266 additions & 26 deletions

File tree

apps/docs/content/docs/en/integrations/sendblue.mdx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Send an iMessage or SMS to a single recipient via Sendblue.
5555
| `content` | string | No | Message text content. Either content or media_url must be provided. |
5656
| `media_url` | string | No | URL of a media file to send. Either content or media_url must be provided. |
5757
| `send_style` | string | No | iMessage expressive style \(e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam\). |
58+
| `seat_id` | string | No | Seat \(user\) the message is attributed to. Accepts the seat UUID or Firebase Auth subject. |
5859
| `status_callback` | string | No | Webhook URL that Sendblue will POST message status updates to. |
5960

6061
#### Output
@@ -85,11 +86,12 @@ Send an iMessage or SMS to a group of recipients via Sendblue.
8586

8687
| Parameter | Type | Required | Description |
8788
| --------- | ---- | -------- | ----------- |
88-
| `numbers` | array | Yes | Recipient phone numbers in E.164 format \(e.g., \["+19998887777", "+13334445555"\]\) |
89+
| `numbers` | array | No | Recipient phone numbers in E.164 format \(e.g., \["+19998887777", "+13334445555"\]\). Optional when sending to an existing group via group_id. |
8990
| `from_number` | string | Yes | One of your registered Sendblue phone numbers to send from, in E.164 format \(e.g., +18887776666\) |
9091
| `content` | string | No | Message text content. Either content or media_url must be provided. |
9192
| `media_url` | string | No | URL of a media file to send. Either content or media_url must be provided. |
9293
| `send_style` | string | No | iMessage expressive style \(e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam\). |
94+
| `seat_id` | string | No | Seat \(user\) the message is attributed to. Accepts the seat UUID or Firebase Auth subject. |
9395
| `group_id` | string | No | Unique identifier of an existing group to send to. Omit to start a new group. |
9496
| `status_callback` | string | No | Webhook URL that Sendblue will POST message status updates to. |
9597

@@ -142,6 +144,8 @@ Display a typing indicator to a recipient (not supported in group chats).
142144
| --------- | ---- | -------- | ----------- |
143145
| `number` | string | Yes | Recipient's phone number in E.164 format \(e.g., +19998887777\) |
144146
| `from_number` | string | No | Your Sendblue line number to send from, in E.164 format. |
147+
| `state` | string | No | "start" \(default\) shows the indicator; "stop" ends an active indicator before max_duration_ms expires. |
148+
| `max_duration_ms` | number | No | How long \(ms\) the indicator stays visible before auto-stopping. Defaults to 60000. Must be between 1 and 300000. |
145149

146150
#### Output
147151

@@ -226,7 +230,7 @@ Trigger when an inbound iMessage or SMS is received in Sendblue
226230
| `was_downgraded` | boolean | True if the recipient lacks iMessage support |
227231
| `plan` | string | Account plan type |
228232
| `message_type` | string | Message category \(e.g., message, group\) |
229-
| `group_id` | string | Group identifier, empty for non-group messages |
233+
| `group_id` | string | Group identifier, null for non-group messages |
230234
| `participants` | array | Participant phone numbers for group messages |
231235
| `send_style` | string | Expressive style if applied |
232236
| `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
266270
| `was_downgraded` | boolean | True if the recipient lacks iMessage support |
267271
| `plan` | string | Account plan type |
268272
| `message_type` | string | Message category \(e.g., message, group\) |
269-
| `group_id` | string | Group identifier, empty for non-group messages |
273+
| `group_id` | string | Group identifier, null for non-group messages |
270274
| `participants` | array | Participant phone numbers for group messages |
271275
| `send_style` | string | Expressive style if applied |
272276
| `opted_out` | boolean | True if the recipient has opted out |

apps/sim/blocks/blocks/sendblue.ts

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,9 @@ export const SendblueBlock: BlockConfig = {
107107
id: 'numbers',
108108
title: 'Recipient Numbers',
109109
type: 'long-input',
110-
placeholder: 'One phone number per line, e.g.\n+19998887777\n+13334445555',
110+
placeholder:
111+
'One phone number per line, e.g.\n+19998887777\n+13334445555\n(optional when sending to an existing Group ID)',
111112
condition: { field: 'operation', value: 'sendblue_send_group_message' },
112-
required: { field: 'operation', value: 'sendblue_send_group_message' },
113113
},
114114
{
115115
id: 'content',
@@ -152,6 +152,17 @@ export const SendblueBlock: BlockConfig = {
152152
mode: 'advanced',
153153
condition: { field: 'operation', value: 'sendblue_send_group_message' },
154154
},
155+
{
156+
id: 'seat_id',
157+
title: 'Seat ID',
158+
type: 'short-input',
159+
placeholder: 'Seat UUID or Firebase Auth subject to attribute the message to',
160+
mode: 'advanced',
161+
condition: {
162+
field: 'operation',
163+
value: ['sendblue_send_message', 'sendblue_send_group_message'],
164+
},
165+
},
155166
{
156167
id: 'status_callback',
157168
title: 'Status Callback URL',
@@ -163,6 +174,26 @@ export const SendblueBlock: BlockConfig = {
163174
value: ['sendblue_send_message', 'sendblue_send_group_message'],
164175
},
165176
},
177+
{
178+
id: 'typing_state',
179+
title: 'Typing State',
180+
type: 'dropdown',
181+
options: [
182+
{ label: 'Start', id: 'start' },
183+
{ label: 'Stop', id: 'stop' },
184+
],
185+
value: () => 'start',
186+
mode: 'advanced',
187+
condition: { field: 'operation', value: 'sendblue_send_typing_indicator' },
188+
},
189+
{
190+
id: 'max_duration_ms',
191+
title: 'Max Duration (ms)',
192+
type: 'short-input',
193+
placeholder: '60000 (1–300000)',
194+
mode: 'advanced',
195+
condition: { field: 'operation', value: 'sendblue_send_typing_indicator' },
196+
},
166197
{
167198
id: 'message_id',
168199
title: 'Message Handle / ID',
@@ -200,32 +231,46 @@ export const SendblueBlock: BlockConfig = {
200231
content: params.content || undefined,
201232
media_url: params.media_url || undefined,
202233
send_style: params.send_style || undefined,
234+
seat_id: params.seat_id || undefined,
203235
status_callback: params.status_callback || undefined,
204236
}
205-
case 'sendblue_send_group_message':
237+
case 'sendblue_send_group_message': {
238+
const parsedNumbers =
239+
typeof params.numbers === 'string'
240+
? params.numbers
241+
.split('\n')
242+
.map((n: string) => n.trim())
243+
.filter(Boolean)
244+
: params.numbers
206245
return {
207246
...base,
208247
numbers:
209-
typeof params.numbers === 'string'
210-
? params.numbers
211-
.split('\n')
212-
.map((n: string) => n.trim())
213-
.filter(Boolean)
214-
: params.numbers,
248+
Array.isArray(parsedNumbers) && parsedNumbers.length === 0
249+
? undefined
250+
: parsedNumbers,
215251
from_number: params.from_number,
216252
content: params.content || undefined,
217253
media_url: params.media_url || undefined,
218254
send_style: params.send_style || undefined,
255+
seat_id: params.seat_id || undefined,
219256
group_id: params.group_id || undefined,
220257
status_callback: params.status_callback || undefined,
221258
}
259+
}
222260
case 'sendblue_evaluate_service':
223261
return { ...base, number: params.number }
224262
case 'sendblue_send_typing_indicator':
225263
return {
226264
...base,
227265
number: params.number,
228266
from_number: params.from_number || undefined,
267+
state: params.typing_state || undefined,
268+
max_duration_ms:
269+
params.max_duration_ms !== undefined &&
270+
params.max_duration_ms !== '' &&
271+
Number.isFinite(Number(params.max_duration_ms))
272+
? Number(params.max_duration_ms)
273+
: undefined,
229274
}
230275
case 'sendblue_get_message':
231276
return { ...base, message_id: params.message_id }
@@ -247,7 +292,13 @@ export const SendblueBlock: BlockConfig = {
247292
media_url: { type: 'string', description: 'URL of media to send' },
248293
send_style: { type: 'string', description: 'iMessage expressive style' },
249294
group_id: { type: 'string', description: 'Existing group ID' },
295+
seat_id: { type: 'string', description: 'Seat (user) the message is attributed to' },
250296
status_callback: { type: 'string', description: 'Status callback webhook URL' },
297+
typing_state: { type: 'string', description: 'Typing indicator state (start or stop)' },
298+
max_duration_ms: {
299+
type: 'number',
300+
description: 'Typing indicator max visible duration in milliseconds',
301+
},
251302
message_id: { type: 'string', description: 'Message handle/ID to retrieve' },
252303
},
253304

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { sendblueHandler } from '@/lib/webhooks/providers/sendblue'
6+
7+
const inboundBody = {
8+
accountEmail: 'me@example.com',
9+
content: 'hello',
10+
media_url: '',
11+
is_outbound: false,
12+
status: 'RECEIVED',
13+
message_handle: 'handle-123',
14+
from_number: '+19998887777',
15+
number: '+18887776666',
16+
group_id: '',
17+
}
18+
19+
const outboundBody = {
20+
...inboundBody,
21+
is_outbound: true,
22+
status: 'SENT',
23+
}
24+
25+
describe('sendblueHandler', () => {
26+
describe('matchEvent', () => {
27+
it('matches an inbound message for the message_received trigger', () => {
28+
expect(
29+
sendblueHandler.matchEvent!({
30+
body: inboundBody,
31+
webhook: { providerConfig: { triggerId: 'sendblue_message_received' } },
32+
requestId: 'r1',
33+
} as any)
34+
).toBe(true)
35+
})
36+
37+
it('rejects an outbound event for the message_received trigger', () => {
38+
expect(
39+
sendblueHandler.matchEvent!({
40+
body: outboundBody,
41+
webhook: { providerConfig: { triggerId: 'sendblue_message_received' } },
42+
requestId: 'r1',
43+
} as any)
44+
).toBe(false)
45+
})
46+
47+
it('matches an outbound status update for the message_status_updated trigger', () => {
48+
expect(
49+
sendblueHandler.matchEvent!({
50+
body: outboundBody,
51+
webhook: { providerConfig: { triggerId: 'sendblue_message_status_updated' } },
52+
requestId: 'r1',
53+
} as any)
54+
).toBe(true)
55+
})
56+
57+
it('passes through when the triggerId is unknown or unset', () => {
58+
expect(
59+
sendblueHandler.matchEvent!({
60+
body: inboundBody,
61+
webhook: {},
62+
requestId: 'r1',
63+
} as any)
64+
).toBe(true)
65+
})
66+
67+
it('rejects a non-object payload for a known trigger', () => {
68+
expect(
69+
sendblueHandler.matchEvent!({
70+
body: 'not-an-object',
71+
webhook: { providerConfig: { triggerId: 'sendblue_message_received' } },
72+
requestId: 'r1',
73+
} as any)
74+
).toBe(false)
75+
})
76+
})
77+
78+
describe('extractIdempotencyId', () => {
79+
it('uses the message handle alone when no status is present', () => {
80+
expect(sendblueHandler.extractIdempotencyId!({ message_handle: 'handle-123' })).toBe(
81+
'handle-123'
82+
)
83+
})
84+
85+
it('suffixes the status so SENT and DELIVERED on one handle stay distinct', () => {
86+
expect(
87+
sendblueHandler.extractIdempotencyId!({ message_handle: 'handle-123', status: 'DELIVERED' })
88+
).toBe('handle-123:DELIVERED')
89+
})
90+
91+
it('returns null when no message handle is present', () => {
92+
expect(sendblueHandler.extractIdempotencyId!({})).toBeNull()
93+
expect(sendblueHandler.extractIdempotencyId!('nope')).toBeNull()
94+
})
95+
})
96+
97+
describe('formatInput', () => {
98+
it('returns the payload under input with empty strings normalized to null', async () => {
99+
const result = await sendblueHandler.formatInput!({ body: inboundBody } as any)
100+
expect(result.input.account_email).toBe('me@example.com')
101+
expect(result.input.media_url).toBeNull()
102+
expect(result.input.group_id).toBeNull()
103+
expect(result.input.is_outbound).toBe(false)
104+
expect(result.input.participants).toEqual([])
105+
expect(result.input.raw).toBe(JSON.stringify(inboundBody))
106+
})
107+
108+
it('defaults missing fields to null and tolerates a non-object body', async () => {
109+
const result = await sendblueHandler.formatInput!({ body: undefined } as any)
110+
expect(result.input.message_handle).toBeNull()
111+
expect(result.input.content).toBeNull()
112+
expect(result.input.participants).toEqual([])
113+
})
114+
})
115+
})

apps/sim/lib/webhooks/providers/sendblue.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ const SENDBLUE_TRIGGER_IS_OUTBOUND: Record<string, boolean> = {
2222
sendblue_message_status_updated: true,
2323
}
2424

25+
/**
26+
* Sendblue webhook handler.
27+
*
28+
* No `verifyAuth` is implemented: Sendblue supports an optional per-webhook
29+
* `secret`/`globalSecret` that it "includes in the webhook request headers,"
30+
* but the official docs never name the header or specify whether the value is
31+
* a plain token echo or an HMAC signature. Implementing verification today
32+
* would require guessing the header name, so it is deferred. When Sendblue
33+
* documents the scheme, wire `verifyTokenAuth` (plain token) or
34+
* `createHmacVerifier` (HMAC) from `@/lib/webhooks/providers/utils` and add a
35+
* secret sub-block to the block definition.
36+
*/
2537
export const sendblueHandler: WebhookProviderHandler = {
2638
matchEvent({ body, webhook, requestId }: EventMatchContext): boolean {
2739
const providerConfig = getProviderConfig(webhook)
@@ -60,7 +72,7 @@ export const sendblueHandler: WebhookProviderHandler = {
6072
input: {
6173
account_email: b.accountEmail ?? b.account_email ?? null,
6274
content: b.content ?? null,
63-
media_url: b.media_url ?? null,
75+
media_url: (typeof b.media_url === 'string' && b.media_url) || null,
6476
is_outbound: b.is_outbound ?? null,
6577
status: b.status ?? null,
6678
error_code: b.error_code ?? null,
@@ -76,7 +88,7 @@ export const sendblueHandler: WebhookProviderHandler = {
7688
was_downgraded: b.was_downgraded ?? null,
7789
plan: b.plan ?? null,
7890
message_type: b.message_type ?? null,
79-
group_id: b.group_id ?? null,
91+
group_id: (typeof b.group_id === 'string' && b.group_id) || null,
8092
participants: b.participants ?? [],
8193
send_style: b.send_style ?? null,
8294
opted_out: b.opted_out ?? null,

apps/sim/tools/sendblue/send_group_message.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ export const sendblueSendGroupMessageTool: ToolConfig<
2323
...sendblueBaseParamFields,
2424
numbers: {
2525
type: 'array',
26-
required: true,
26+
required: false,
2727
visibility: 'user-or-llm',
2828
description:
29-
'Recipient phone numbers in E.164 format (e.g., ["+19998887777", "+13334445555"])',
29+
'Recipient phone numbers in E.164 format (e.g., ["+19998887777", "+13334445555"]). Optional when sending to an existing group via group_id.',
3030
items: { type: 'string', description: 'Phone number in E.164 format' },
3131
},
3232
from_number: {
@@ -55,6 +55,13 @@ export const sendblueSendGroupMessageTool: ToolConfig<
5555
description:
5656
'iMessage expressive style (e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam).',
5757
},
58+
seat_id: {
59+
type: 'string',
60+
required: false,
61+
visibility: 'user-or-llm',
62+
description:
63+
'Seat (user) the message is attributed to. Accepts the seat UUID or Firebase Auth subject.',
64+
},
5865
group_id: {
5966
type: 'string',
6067
required: false,
@@ -73,16 +80,28 @@ export const sendblueSendGroupMessageTool: ToolConfig<
7380
url: `${SENDBLUE_API_BASE_URL}/api/send-group-message`,
7481
method: 'POST',
7582
headers: (params) => sendblueHeaders(params),
76-
body: (params) =>
77-
filterUndefined({
78-
numbers: params.numbers,
83+
body: (params) => {
84+
const numbers = Array.isArray(params.numbers)
85+
? params.numbers.map((n) => n.trim()).filter(Boolean)
86+
: undefined
87+
const hasNumbers = numbers !== undefined && numbers.length > 0
88+
const hasGroupId = typeof params.group_id === 'string' && params.group_id.trim().length > 0
89+
if (!hasNumbers && !hasGroupId) {
90+
throw new Error(
91+
'Provide either "numbers" to start a new group or "group_id" to message an existing group.'
92+
)
93+
}
94+
return filterUndefined({
95+
numbers: hasNumbers ? numbers : undefined,
7996
from_number: params.from_number,
8097
content: params.content,
8198
media_url: params.media_url,
8299
send_style: params.send_style,
83-
group_id: params.group_id,
100+
seat_id: params.seat_id,
101+
group_id: hasGroupId ? params.group_id?.trim() : undefined,
84102
status_callback: params.status_callback,
85-
}),
103+
})
104+
},
86105
},
87106

88107
transformResponse: async (response) => {

0 commit comments

Comments
 (0)