Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/docs/content/docs/en/integrations/sendblue.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
69 changes: 60 additions & 9 deletions apps/sim/blocks/blocks/sendblue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -200,32 +231,46 @@ 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,
Comment thread
waleedlatif1 marked this conversation as resolved.
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':
return {
...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,
Comment thread
waleedlatif1 marked this conversation as resolved.
}
case 'sendblue_get_message':
return { ...base, message_id: params.message_id }
Expand All @@ -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' },
},

Expand Down
115 changes: 115 additions & 0 deletions apps/sim/lib/webhooks/providers/sendblue.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
})
16 changes: 14 additions & 2 deletions apps/sim/lib/webhooks/providers/sendblue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
33 changes: 26 additions & 7 deletions apps/sim/tools/sendblue/send_group_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ export const sendblueSendGroupMessageTool: ToolConfig<
...sendblueBaseParamFields,
numbers: {
type: 'array',
required: true,
required: false,
Comment thread
waleedlatif1 marked this conversation as resolved.
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: {
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Comment thread
waleedlatif1 marked this conversation as resolved.
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) => {
Expand Down
Loading
Loading