mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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 |
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user