diff --git a/.changeset/quiet-phones-send.md b/.changeset/quiet-phones-send.md new file mode 100644 index 0000000..f1a6ca4 --- /dev/null +++ b/.changeset/quiet-phones-send.md @@ -0,0 +1,7 @@ +--- +'@imessage-sdk/sendblue': minor +--- + +Add the initial Sendblue provider with direct text and single-attachment sends, message lookup and +status, typing indicators, opt-in mark-read, add-only tapbacks, authenticated webhooks, provider +error mapping, and provider-aware capability types. diff --git a/.env.example b/.env.example index 6fdf60d..aa2578b 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,13 @@ PHOTON_TEST_RECIPIENT= PHOTON_TEST_IMAGE_URL= PHOTON_TEST_VIDEO_URL= PHOTON_TEST_FILE_URL= + +SENDBLUE_API_KEY= +SENDBLUE_API_SECRET= +SENDBLUE_FROM_NUMBER= +SENDBLUE_WEBHOOK_SECRET= +SENDBLUE_TEST_RECIPIENT= +SENDBLUE_TEST_IMAGE_URL= +SENDBLUE_TEST_VIDEO_URL= +SENDBLUE_TEST_FILE_URL= +SENDBLUE_TEST_MARK_READ= diff --git a/AGENTS.md b/AGENTS.md index 96b69c1..ec094f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,6 @@ depend on the public `imessage-sdk` interface rather than provider internals. | `packages/providers/` | Independently published provider package | | `packages/providers/README.md` | Cross-provider feature support matrix | | `packages/chat-adapter` | Chat SDK integration (`@imessage-sdk/chat-adapter`) | -| `packages/eve-channel` | Private placeholder for `@imessage-sdk/eve-channel` | | `packages/cli` | Private placeholder for `@imessage-sdk/cli` | | `examples/basic-blooio` | Opt-in live example using only published Blooio and core APIs | | `test/package-consumer` | Clean TypeScript consumer used by package smoke tests | diff --git a/README.md b/README.md index 9f8f0a4..e13536c 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ const client = createIMessageClient({ P.S. remember to create your own [Blooio account](https://app.blooio.com/signup?ref=BLOO-2NS4AJM8) and configure the provider with your credentials. -Available normalized v0.1 operations, including signed webhook verification and normalized webhook -events, are stable. +The stable v0.1 providers are Blooio, Photon, and Sendblue. See the +[provider feature matrix](./packages/providers/README.md) for their verified surfaces and current +limitations. See the [package README](./packages/imessage-sdk/README.md) for the public API, provider configuration, capability boundary, and live integration tests. @@ -58,9 +59,9 @@ packages/ ├── imessage-sdk/ Provider-neutral core package ├── providers/ │ ├── blooio/ @imessage-sdk/blooio -│ └── photon/ @imessage-sdk/photon +│ ├── photon/ @imessage-sdk/photon +│ └── sendblue/ @imessage-sdk/sendblue ├── chat-adapter/ @imessage-sdk/chat-adapter -├── eve-channel/ Private placeholder for @imessage-sdk/eve-channel └── cli/ Private placeholder for @imessage-sdk/cli examples/ └── basic-blooio/ Opt-in live Blooio API and webhook example @@ -120,7 +121,7 @@ Before publishing, the same release command used by automation also packs each public package, runs Publint and Are the Types Wrong, installs all tarballs in a clean strict-TypeScript consumer, and checks every public import. -Releases publish under npm's `latest` dist-tag. See +Stable releases publish under npm's `latest` dist-tag, while prereleases publish under `beta`. See [RELEASING.md](./RELEASING.md) for the complete maintainer workflow. ### Release automation setup diff --git a/RELEASING.md b/RELEASING.md index 501323e..f9f96d2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,14 +5,14 @@ requests, npm trusted publishing, and package-specific GitHub Releases. ## What is published -| Directory | npm package | Status | -| --------------------------- | ---------------------------- | ------------------- | -| `packages/imessage-sdk` | `imessage-sdk` | Public | -| `packages/providers/blooio` | `@imessage-sdk/blooio` | Public | -| `packages/providers/photon` | `@imessage-sdk/photon` | Public | -| `packages/chat-adapter` | `@imessage-sdk/chat-adapter` | Public | -| `packages/eve-channel` | `@imessage-sdk/eve-channel` | Private placeholder | -| `packages/cli` | `@imessage-sdk/cli` | Private placeholder | +| Directory | npm package | Status | +| ----------------------------- | ---------------------------- | ------------------- | +| `packages/imessage-sdk` | `imessage-sdk` | Public | +| `packages/providers/blooio` | `@imessage-sdk/blooio` | Public | +| `packages/providers/photon` | `@imessage-sdk/photon` | Public | +| `packages/providers/sendblue` | `@imessage-sdk/sendblue` | Public | +| `packages/chat-adapter` | `@imessage-sdk/chat-adapter` | Public | +| `packages/cli` | `@imessage-sdk/cli` | Private placeholder | ## One-time local setup @@ -41,9 +41,9 @@ Normal releases run in GitHub Actions through OIDC and need neither a local 3. Bootstrap a new package name manually before configuring OIDC for it. 4. Configure trusted publishing separately for every public package. -The initial prereleases currently own `latest` as a consequence of package -bootstrap. The first stable release will replace it with `0.1.0`; no dist-tag -removal is required. +A package's first prerelease may also receive `latest` as a consequence of npm +bootstrap. Its first stable release replaces that tag; no dist-tag removal is +required. ## One-time GitHub setup @@ -101,7 +101,8 @@ changelogs. ## Trusted publishing on npm Configure trusted publishing separately in the settings for `imessage-sdk`, -`@imessage-sdk/blooio`, `@imessage-sdk/photon`, and `@imessage-sdk/chat-adapter`: +`@imessage-sdk/blooio`, `@imessage-sdk/photon`, `@imessage-sdk/sendblue`, and +`@imessage-sdk/chat-adapter`: ```text Provider: GitHub Actions @@ -128,11 +129,15 @@ npm cannot configure a trusted publisher for a package that does not exist. For the first version of a future provider or adapter: 1. Merge its package and changeset through the normal reviewed PR flow. -2. Build and pack it from the exact `main` commit that will be tagged. -3. Inspect and publish the tarball locally under `beta`. -4. Configure that package's npm trusted publisher. -5. Backfill the matching Git tag and GitHub prerelease if automation did not - create them. +2. Review and merge the generated Version Packages pull request. The first + automated publish can fail with npm `E404` because trusted publishing cannot + be configured until the package exists. +3. Check out the resulting `main` commit, then build and pack the versioned + package from that exact commit. +4. Inspect and publish the tarball locally under the intended dist-tag. +5. Configure that package's npm trusted publisher. +6. Backfill the matching Git tag and GitHub Release if automation did not create + them. Mark the GitHub Release as a prerelease only for a prerelease version. For example: @@ -146,7 +151,17 @@ pnpm package:check PACKAGE_DIR=$(mktemp -d) pnpm --filter @imessage-sdk/ pack --pack-destination "$PACKAGE_DIR" -npm publish "$PACKAGE_DIR/.tgz" --tag beta --access public --provenance=false +npm publish "$PACKAGE_DIR/.tgz" --access public --provenance=false +``` + +Add `--tag beta` when bootstrapping a prerelease. For the initial stable Sendblue release, use: + +```bash +PACKAGE_DIR=$(mktemp -d) +pnpm --filter @imessage-sdk/sendblue pack --pack-destination "$PACKAGE_DIR" +npm publish "$PACKAGE_DIR/imessage-sdk-sendblue-0.1.0.tgz" \ + --access public \ + --provenance=false ``` Direct package publishing commands can incorrectly request provenance outside @@ -261,10 +276,12 @@ imessage-sdk@0.1.0-beta.1 Confirm that npm versions and dist-tags match the generated package versions: ```bash -npm view imessage-sdk@beta version +npm view imessage-sdk version npm dist-tag ls imessage-sdk -npm view @imessage-sdk/blooio@beta version +npm view @imessage-sdk/blooio version npm dist-tag ls @imessage-sdk/blooio +npm view @imessage-sdk/sendblue version +npm dist-tag ls @imessage-sdk/sendblue ``` Then install the release in a clean external project using `@beta` during the diff --git a/packages/eve-channel/package.json b/packages/eve-channel/package.json deleted file mode 100644 index 839faaa..0000000 --- a/packages/eve-channel/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@imessage-sdk/eve-channel", - "version": "0.0.0", - "private": true, - "description": "Eve channel powered by imessage-sdk", - "license": "MIT", - "type": "module", - "dependencies": { - "imessage-sdk": "workspace:^" - } -} diff --git a/packages/providers/README.md b/packages/providers/README.md index a92b880..6451ae7 100644 --- a/packages/providers/README.md +++ b/packages/providers/README.md @@ -3,29 +3,30 @@ Provider adapters are independently installable packages built on the public `imessage-sdk` contract. -| Capability | Blooio | Photon Cloud | -| ----------------------------- | ---------------------- | --------------------------------- | -| Package | `@imessage-sdk/blooio` | `@imessage-sdk/photon` | -| Send text | ✅ | ✅ | -| Send public URL attachments | ✅ | ✅ | -| Send `Blob` attachments | — | ✅ | -| Send `Uint8Array` attachments | — | ✅ | -| Access inbound attachments | Public URL | Authenticated byte download | -| Reply to a message | ✅ | ✅ | -| Get a message | ✅ | ✅ | -| Edit a message | — | — | -| Delete or unsend a message | — | — | -| Open a direct conversation | ✅ | ✅ | -| Group conversations | — | Experimental, provider-level only | -| Get a conversation | ✅ | ✅ | -| Mark a conversation as read | ✅ | ✅ | -| Add and remove reactions | ✅ | ✅ | -| Start and stop typing | ✅ | ✅ | -| Read receipts | ✅ | ✅ | -| Signed webhooks | ✅ | ✅ | -| Normalized event stream | — | — | -| Provider-level event stream | — | Experimental | -| Sender or line discovery | Linked numbers | Connected line | +| Capability | Blooio | Photon Cloud | Sendblue | +| ----------------------------- | ---------------------- | --------------------------------- | ------------------------ | +| Package | `@imessage-sdk/blooio` | `@imessage-sdk/photon` | `@imessage-sdk/sendblue` | +| Send text | ✅ | ✅ | ✅ | +| Send public URL attachments | ✅ | ✅ | ✅ (one per message) | +| Send `Blob` attachments | — | ✅ | ✅ (one per message) | +| Send `Uint8Array` attachments | — | ✅ | ✅ (one per message) | +| Access inbound attachments | Public URL | Authenticated byte download | Expiring public URL | +| Reply to a message | ✅ | ✅ | — | +| Get a message | ✅ | ✅ | ✅ | +| Edit a message | — | — | — | +| Delete or unsend a message | — | — | — | +| Open a direct conversation | ✅ | ✅ | ✅ | +| Group conversations | — | Experimental, provider-level only | — | +| Get a conversation | ✅ | ✅ | — | +| Mark a conversation as read | ✅ | ✅ | Account-dependent | +| Add and remove reactions | ✅ | ✅ | — | +| Provider-level tapback add | — | — | ✅ | +| Start and stop typing | ✅ | ✅ | ✅ | +| Read receipts | ✅ | ✅ | — | +| Authenticated webhooks | Signed | Signed | Shared-secret header | +| Normalized event stream | — | — | — | +| Provider-level event stream | — | Experimental | — | +| Sender or line discovery | Linked numbers | Connected line | Configured sender | `—` means the normalized v0.1 capability is unavailable. Unsupported normalized operations throw `UnsupportedCapabilityError` rather than silently @@ -35,8 +36,13 @@ Photon group conversations and streaming exist behind provider-specific APIs, but their normalized capabilities remain disabled until their behavior and integration tests are stable enough for the v0.1 contract. -All available normalized v0.1 operations, including webhook verification and event normalization, -are stable. +Sendblue accepts one outbound attachment per message in v0.1. Its normalized reaction capability +remains disabled because the documented API can add a tapback but cannot reliably remove one. The +add-only operation remains available through the concrete Sendblue provider. The Sendblue +mark-read endpoint depends on account support and must be explicitly enabled. + +The published Blooio, Photon, and Sendblue v0.1 operations are stable. Their live integration suites +remain opt-in because they contact real provider accounts, send messages, and mutate provider state. ## Installation @@ -45,6 +51,7 @@ Install the core together with exactly the providers an application uses: ```bash pnpm add imessage-sdk @imessage-sdk/blooio pnpm add imessage-sdk @imessage-sdk/photon +pnpm add imessage-sdk @imessage-sdk/sendblue ``` ```ts @@ -60,6 +67,7 @@ See each provider package for configuration and live integration-test details: - [`@imessage-sdk/blooio`](./blooio) - [`@imessage-sdk/photon`](./photon) +- [`@imessage-sdk/sendblue`](./sendblue) For an executable end-to-end Blooio walkthrough, see [`examples/basic-blooio`](../../examples/basic-blooio). diff --git a/packages/providers/sendblue/LICENSE b/packages/providers/sendblue/LICENSE new file mode 100644 index 0000000..6bb425f --- /dev/null +++ b/packages/providers/sendblue/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 imessage-sdk contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/providers/sendblue/README.md b/packages/providers/sendblue/README.md new file mode 100644 index 0000000..73c0f07 --- /dev/null +++ b/packages/providers/sendblue/README.md @@ -0,0 +1,128 @@ +# @imessage-sdk/sendblue + +[Sendblue](https://www.sendblue.com/) API v2 provider for +[`imessage-sdk`](https://www.npmjs.com/package/imessage-sdk). + +## Install + +```bash +pnpm add imessage-sdk @imessage-sdk/sendblue +``` + +## Usage + +```ts +import { sendblue } from '@imessage-sdk/sendblue'; +import { createIMessageClient } from 'imessage-sdk'; + +const client = createIMessageClient({ + provider: sendblue(), +}); + +await client.messages.send({ + to: { kind: 'phone', value: '+15551234567' }, + text: 'Hello from Sendblue', + attachments: [ + { + kind: 'image', + source: { + type: 'url', + url: 'https://cdn.example.com/photo.jpg', + }, + }, + ], +}); +``` + +`SENDBLUE_API_KEY`, `SENDBLUE_API_SECRET`, and `SENDBLUE_FROM_NUMBER` are required for API +operations. `SENDBLUE_WEBHOOK_SECRET` is additionally required when handling webhooks. +`sendblue()` reads all four variables automatically, and explicit options override environment +values. One provider instance represents one configured Sendblue line. + +Sendblue currently offers a no-credit-card free sandbox with a shared line and verified-contact +restrictions, so the integration can be exercised before selecting a production plan. + +## Attachments + +Sendblue accepts one attachment per direct message. The provider supports images, videos, and +files from: + +- a publicly accessible URL; +- a `Blob`; +- a `Uint8Array`. + +URLs are passed directly to Sendblue. They must be public, unsigned HTTP(S) URLs with a usable file +extension. Blob and byte sources are uploaded through Sendblue's file upload endpoint before the +message is sent. Sendblue documents a 100 MB maximum upload size and recommends keeping attachments +below 20 MB for reliable iMessage delivery. + +Inbound media is normalized as an attachment with a public Sendblue CDN URL. Sendblue documents +that inbound media URLs expire after 30 days, so applications that need longer retention should +copy the file promptly. + +## Account-gated mark read + +Sendblue requires the manual mark-read endpoint to be activated for the account. Enable the SDK +capability only after Sendblue confirms activation: + +```ts +const client = createIMessageClient({ + provider: sendblue({ markReadEnabled: true }), +}); + +await client.conversations.markRead('+15551234567'); +``` + +## Tapbacks + +Sendblue currently documents adding tapbacks but not removing them, so tapbacks are exposed as a +provider-specific API rather than the normalized reactions API: + +```ts +await client.providers.sendblue.tapbacks.add({ + conversationId: '+15551234567', + messageId: 'provider-message-handle', + reaction: 'love', +}); +``` + +## Webhooks + +Configure `receive`, `outbound`, and `typing_indicator` webhooks for the account in Sendblue, and +set the same secret as `SENDBLUE_WEBHOOK_SECRET`. The provider verifies Sendblue's +`sb-signing-secret` header, filters account-wide events to `SENDBLUE_FROM_NUMBER`, and normalizes +the accepted payloads: + +```ts +const events = await client.webhooks.handle(request); +``` + +Inbound media is exposed through its Sendblue CDN URL. A webhook can contain both text and one +attachment, or an attachment without text. + +## Current limitations + +- Direct phone conversations only; normalized groups are disabled. +- One attachment per message. +- Replies, editing, native unsend, and event streams are disabled. +- Normalized reactions are disabled because Sendblue does not document tapback removal. +- Observed read-receipt events are disabled. +- Sendblue may automatically downgrade iMessage to SMS; the normalized message preserves the + resulting service and provider status. + +No operation is retried automatically. If a message send has an uncertain result, the provider +throws `AmbiguousDeliveryError` so callers can inspect status before deciding whether to retry. + +Run the opt-in live test from the repository root: + +```bash +pnpm --filter @imessage-sdk/sendblue test:integration +``` + +The live test contacts Sendblue and sends real messages. Before running it, send a fresh iMessage +from `SENDBLUE_TEST_RECIPIENT` to `SENDBLUE_FROM_NUMBER`. The suite discovers that inbound message, +adds a `like` tapback first, then exercises text plus separate image, video, and file messages. + +Set `SENDBLUE_TEST_MARK_READ=1` only if Sendblue has activated manual mark-read for the test account. +When enabled, the suite marks the fresh inbound conversation as read before sending the outbound +fixtures. diff --git a/packages/providers/sendblue/package.json b/packages/providers/sendblue/package.json new file mode 100644 index 0000000..05e6112 --- /dev/null +++ b/packages/providers/sendblue/package.json @@ -0,0 +1,58 @@ +{ + "name": "@imessage-sdk/sendblue", + "version": "0.0.0", + "description": "Sendblue provider for imessage-sdk", + "author": "jmisilo", + "keywords": [ + "imessage", + "sendblue", + "messaging", + "typescript" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/jmisilo/imessage-sdk.git", + "directory": "packages/providers/sendblue" + }, + "homepage": "https://github.com/jmisilo/imessage-sdk/tree/main/packages/providers/sendblue#readme", + "bugs": { + "url": "https://github.com/jmisilo/imessage-sdk/issues" + }, + "license": "MIT", + "type": "module", + "sideEffects": false, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:integration": "SENDBLUE_LIVE_TEST=1 vitest run test/sendblue.integration.test.ts", + "test:watch": "vitest", + "typecheck": "tsc --project tsconfig.json --noEmit" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "dependencies": { + "imessage-sdk": "workspace:^", + "zod": "^4.4.3" + } +} diff --git a/packages/providers/sendblue/src/index.ts b/packages/providers/sendblue/src/index.ts new file mode 100644 index 0000000..9b6b558 --- /dev/null +++ b/packages/providers/sendblue/src/index.ts @@ -0,0 +1,933 @@ +import { z } from 'zod'; + +import type { + AddReactionInput, + IMessageAddress, + IMessageAttachment, + IMessageAttachmentInput, + IMessageCapabilities, + IMessageProvider, + IMessageService, + IMessageStatus, + MessageLocator, + OpenConversationInput, + ProviderConversation, + ProviderConversations, + ProviderEvent, + ProviderMessage, + ProviderMessages, + ProviderSentMessage, + ProviderTyping, + ProviderWebhooks, +} from 'imessage-sdk'; +import { + AmbiguousDeliveryError, + AuthenticationError, + ConflictError, + defineProvider, + IMessageSDKError, + NotFoundError, + ProviderUnavailableError, + RateLimitError, + ValidationError, +} from 'imessage-sdk'; + +const DEFAULT_BASE_URL = 'https://api.sendblue.com'; +const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024; +const MAX_MESSAGE_LENGTH = 18_996; + +export const SENDBLUE_CAPABILITIES = { + attachments: { + download: false, + }, + messages: { + text: true, + attachments: true, + replies: false, + get: true, + edit: false, + delete: false, + }, + conversations: { + direct: true, + groups: false, + get: false, + markRead: false, + }, + interactions: { + reactions: false, + typingStart: true, + typingStop: true, + readReceipts: false, + }, + events: { + webhooks: true, + stream: false, + }, +} as const satisfies IMessageCapabilities; + +export type SendblueCapabilities = Omit< + typeof SENDBLUE_CAPABILITIES, + 'conversations' +> & { + readonly conversations: Omit & { + readonly markRead: TMarkReadEnabled; + }; +}; + +interface SendblueBaseOptions { + readonly apiKey?: string; + readonly apiSecret?: string; + readonly fromNumber?: string; + readonly webhookSecret?: string; + readonly baseUrl?: string; +} + +export type SendblueOptions = SendblueBaseOptions & + ([TMarkReadEnabled] extends [true] + ? { + /** Enable only after Sendblue has activated the manual mark-read endpoint. */ + readonly markReadEnabled: true; + } + : [TMarkReadEnabled] extends [false] + ? { readonly markReadEnabled?: false } + : { + /** A dynamic boolean produces a correspondingly dynamic capability type. */ + readonly markReadEnabled: TMarkReadEnabled; + }); + +export interface SendblueMessageStatus { + readonly messageId: string; + readonly conversationId: string; + readonly status: IMessageStatus; + readonly providerStatus?: string; + readonly service: IMessageService; + readonly sentAt?: Date; + readonly deliveredAt?: Date; + readonly error?: string; + readonly raw: unknown; +} + +export interface SendblueMessages extends Omit { + get(message: MessageLocator): Promise; + getStatus(message: MessageLocator): Promise; +} + +export type SendblueConversations = Omit< + ProviderConversations, + 'get' | 'markRead' +> & + (TMarkReadEnabled extends true + ? { markRead(conversationId: string): Promise } + : Record); + +export interface SendblueTapbacks { + /** Sendblue currently documents adding tapbacks, but not removing them. */ + add(input: AddReactionInput): Promise; +} + +export interface SendblueProvider< + TMarkReadEnabled extends boolean = false, +> extends IMessageProvider<'sendblue', SendblueCapabilities> { + readonly messages: SendblueMessages; + readonly conversations: SendblueConversations; + readonly typing: Required; + readonly webhooks: ProviderWebhooks; + readonly tapbacks: SendblueTapbacks; +} + +const E164Schema = z.string().regex(/^\+[1-9]\d{6,14}$/u, 'Expected an E.164 phone number.'); +const OptionalStringSchema = z + .string() + .nullable() + .optional() + .transform((value) => (value === null || value === '' ? undefined : value)); +const OptionalDateSchema = z + .union([z.string(), z.number().finite()]) + .nullable() + .optional() + .transform((value) => (value === null || value === '' ? undefined : value)); + +const MessageSchema = z + .object({ + message_handle: z.string().min(1), + content: z.string().nullable().optional(), + is_outbound: z.boolean().optional(), + status: OptionalStringSchema, + date_created: OptionalDateSchema, + date_sent: OptionalDateSchema, + date_delivered: OptionalDateSchema, + date_read: OptionalDateSchema, + date_updated: OptionalDateSchema, + from_number: OptionalStringSchema, + number: OptionalStringSchema, + to_number: OptionalStringSchema, + sendblue_number: OptionalStringSchema, + media_url: OptionalStringSchema, + service: OptionalStringSchema, + was_downgraded: z.boolean().nullable().optional(), + message_type: OptionalStringSchema, + group_id: OptionalStringSchema, + error_code: z.union([z.string(), z.number()]).nullable().optional(), + error_message: OptionalStringSchema, + error_reason: OptionalStringSchema, + error_detail: OptionalStringSchema, + }) + .loose(); + +const MessageEnvelopeSchema = z.object({ data: z.unknown() }).loose(); +const UploadResponseSchema = z.object({ media_url: z.url() }).loose(); +const ApiErrorSchema = z + .object({ + message: OptionalStringSchema, + error: OptionalStringSchema, + error_message: OptionalStringSchema, + error_code: z.union([z.string(), z.number()]).nullable().optional(), + status_code: z.number().optional(), + }) + .loose(); +const TypingWebhookSchema = z + .object({ + number: E164Schema, + is_typing: z.boolean(), + from_number: E164Schema, + timestamp: OptionalDateSchema, + }) + .loose(); + +type SendblueMessagePayload = z.infer; + +function address(value: string): IMessageAddress { + return { kind: 'phone', value }; +} + +function parseDate(value: string | number | null | undefined): Date | undefined { + if (value === undefined || value === null) return undefined; + const result = new Date(value); + return Number.isNaN(result.valueOf()) ? undefined : result; +} + +function mapStatus(value: string | null | undefined): IMessageStatus { + switch (value?.toUpperCase()) { + case 'REGISTERED': + case 'PENDING': + case 'QUEUED': + return 'pending'; + case 'ACCEPTED': + return 'accepted'; + case 'SUCCESS': + case 'SENT': + return 'sent'; + case 'RECEIVED': + case 'DELIVERED': + return 'delivered'; + case 'READ': + return 'read'; + case 'DECLINED': + case 'ERROR': + case 'FAILED': + return 'failed'; + default: + return 'pending'; + } +} + +function mapService(value: string | null | undefined): IMessageService { + switch (value?.toLowerCase()) { + case 'imessage': + return 'imessage'; + case 'sms': + case 'mms': + return 'sms'; + case 'rcs': + return 'rcs'; + default: + return 'unknown'; + } +} + +function attachmentKind(url: string): IMessageAttachment['kind'] { + const path = url.split(/[?#]/u, 1)[0]?.toLowerCase() ?? ''; + if (/\.(?:avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/u.test(path)) return 'image'; + if (/\.(?:3gp|avi|m4v|mkv|mov|mp4|mpeg|mpg|webm)$/u.test(path)) return 'video'; + return 'file'; +} + +function contactNumber(raw: SendblueMessagePayload, fallback: string): string { + if (raw.message_type === 'group' || raw.group_id !== undefined) { + return raw.group_id ?? fallback; + } + if (raw.is_outbound === true) return raw.number ?? raw.to_number ?? fallback; + return raw.number ?? raw.from_number ?? fallback; +} + +function mapProviderMessage( + raw: SendblueMessagePayload, + fallbackConversationId: string, + configuredFromNumber?: string, + sourceRaw: unknown = raw, +): ProviderMessage { + const direction = raw.is_outbound === true ? 'outbound' : 'inbound'; + const conversationId = contactNumber(raw, fallbackConversationId); + const ownNumber = raw.sendblue_number ?? configuredFromNumber ?? raw.to_number ?? 'unknown'; + const senderValue = direction === 'outbound' ? (raw.from_number ?? ownNumber) : conversationId; + const recipientValue = + direction === 'outbound' ? (raw.to_number ?? raw.number ?? conversationId) : ownNumber; + const providerStatus = raw.status ?? undefined; + const status = mapStatus(providerStatus); + const createdAt = + parseDate(raw.date_sent) ?? + parseDate(raw.date_created) ?? + parseDate(raw.date_updated) ?? + new Date(); + const updatedAt = parseDate(raw.date_updated); + const deliveredAt = + parseDate(raw.date_delivered) ?? + (status === 'delivered' || status === 'read' ? updatedAt : undefined); + const readAt = parseDate(raw.date_read) ?? (status === 'read' ? updatedAt : undefined); + const mediaUrl = raw.media_url === null || raw.media_url === '' ? undefined : raw.media_url; + const attachments: readonly IMessageAttachment[] = + mediaUrl === undefined + ? [] + : [ + { + kind: attachmentKind(mediaUrl), + url: mediaUrl, + raw: { media_url: mediaUrl }, + }, + ]; + return { + providerMessageId: raw.message_handle, + conversationId, + direction, + sender: address(senderValue), + recipients: [address(recipientValue)], + text: raw.content ?? '', + attachments, + service: raw.was_downgraded === true ? 'sms' : mapService(raw.service), + status, + ...(providerStatus === undefined ? {} : { providerStatus }), + createdAt, + ...(direction === 'outbound' ? { sentAt: parseDate(raw.date_sent) ?? createdAt } : {}), + ...(deliveredAt === undefined ? {} : { deliveredAt }), + ...(readAt === undefined ? {} : { readAt }), + raw: sourceRaw, + }; +} + +function parseMessage(raw: unknown): SendblueMessagePayload | undefined { + const envelope = MessageEnvelopeSchema.safeParse(raw); + const candidate = envelope.success ? envelope.data.data : raw; + const parsed = MessageSchema.safeParse(candidate); + return parsed.success ? parsed.data : undefined; +} + +function requirePhone(value: string, field: string): string { + const parsed = E164Schema.safeParse(value); + if (!parsed.success) { + throw new ValidationError(`${field} must be an E.164 phone number.`, { + provider: 'sendblue', + code: 'invalid_phone_number', + raw: parsed.error, + }); + } + return parsed.data; +} + +function oneRecipient(input: { + readonly conversationId?: string; + readonly to?: IMessageAddress | readonly IMessageAddress[]; +}): string { + if (input.conversationId !== undefined) { + return requirePhone(input.conversationId, 'conversationId'); + } + const recipients = input.to === undefined ? [] : 'value' in input.to ? [input.to] : input.to; + if (recipients.length !== 1 || recipients[0]?.kind !== 'phone') { + throw new ValidationError('Sendblue requires exactly one phone recipient.', { + provider: 'sendblue', + code: 'single_phone_recipient_required', + }); + } + return requirePhone(recipients[0].value, 'to'); +} + +function filenameForAttachment(attachment: { + readonly kind: IMessageAttachment['kind']; + readonly filename?: string; + readonly contentType?: string; +}): string { + if (attachment.filename !== undefined && attachment.filename.length > 0) { + return attachment.filename; + } + const extension = + attachment.contentType?.split('/')[1]?.split(/[;+]/u, 1)[0] ?? + (attachment.kind === 'image' ? 'jpg' : attachment.kind === 'video' ? 'mp4' : 'bin'); + return `attachment.${extension}`; +} + +function constantTimeEqual(left: string, right: string): boolean { + if (left.length !== right.length) return false; + let result = 0; + for (let index = 0; index < left.length; index += 1) { + result |= left.charCodeAt(index) ^ right.charCodeAt(index); + } + return result === 0; +} + +function webhookLine(raw: SendblueMessagePayload): string | undefined { + if (raw.sendblue_number !== undefined && raw.sendblue_number !== null) return raw.sendblue_number; + return raw.is_outbound === true ? (raw.from_number ?? undefined) : (raw.to_number ?? undefined); +} + +function isDirectMessage(raw: SendblueMessagePayload): boolean { + return raw.message_type !== 'group' && raw.group_id === undefined; +} + +function mapWebhookMessage( + raw: SendblueMessagePayload, + sourceRaw: unknown = raw, +): ProviderEvent | undefined { + if (!isDirectMessage(raw)) return undefined; + const providerStatus = raw.status?.toUpperCase(); + const timestamp = parseDate(raw.date_updated) ?? parseDate(raw.date_sent) ?? new Date(); + let type: 'message.received' | 'message.sent' | 'message.delivered' | 'message.failed'; + if (raw.is_outbound !== true) { + type = 'message.received'; + } else if (providerStatus === 'DELIVERED') { + type = 'message.delivered'; + } else if ( + providerStatus === 'ERROR' || + providerStatus === 'DECLINED' || + providerStatus === 'FAILED' + ) { + type = 'message.failed'; + } else if (providerStatus === 'READ') { + return undefined; + } else { + type = 'message.sent'; + } + return { + id: `sendblue:${raw.message_handle}:${providerStatus ?? 'UNKNOWN'}:${timestamp.valueOf()}`, + type, + timestamp, + message: mapProviderMessage(raw, contactNumber(raw, 'unknown'), undefined, sourceRaw), + raw: sourceRaw, + }; +} + +function requirePublicUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch (cause) { + throw new ValidationError('Sendblue attachment URLs must be valid public HTTP(S) URLs.', { + provider: 'sendblue', + code: 'invalid_attachment_url', + raw: cause, + }); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new ValidationError('Sendblue attachment URLs must be valid public HTTP(S) URLs.', { + provider: 'sendblue', + code: 'invalid_attachment_url', + raw: value, + }); + } + return value; +} + +function assertOperationSucceeded(raw: unknown, operation: string): void { + const parsed = ApiErrorSchema.safeParse(raw); + if (!parsed.success) return; + const status = + typeof raw === 'object' && raw !== null && 'status' in raw + ? (raw as { readonly status?: unknown }).status + : undefined; + if (typeof status !== 'string' || status.toUpperCase() !== 'ERROR') return; + throw new IMessageSDKError( + parsed.data.error_message ?? + parsed.data.message ?? + parsed.data.error ?? + `Sendblue ${operation} failed.`, + { + provider: 'sendblue', + code: + parsed.data.error_code === undefined || parsed.data.error_code === null + ? 'sendblue_operation_failed' + : String(parsed.data.error_code), + raw, + }, + ); +} + +/** Creates a Sendblue API v2 provider. No initialization call is required. */ +export function sendblue(): SendblueProvider; +export function sendblue(options: SendblueOptions): SendblueProvider; +export function sendblue(options: SendblueOptions): SendblueProvider; +export function sendblue(options: SendblueOptions): SendblueProvider; +export function sendblue( + options: SendblueOptions | SendblueOptions | SendblueOptions = {}, +): SendblueProvider { + const apiKey = options.apiKey ?? process.env['SENDBLUE_API_KEY']; + const apiSecret = options.apiSecret ?? process.env['SENDBLUE_API_SECRET']; + const fromNumber = options.fromNumber ?? process.env['SENDBLUE_FROM_NUMBER']; + const webhookSecret = options.webhookSecret ?? process.env['SENDBLUE_WEBHOOK_SECRET']; + const markReadEnabled = options.markReadEnabled ?? false; + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/u, ''); + const capabilities = { + ...SENDBLUE_CAPABILITIES, + conversations: { + ...SENDBLUE_CAPABILITIES.conversations, + markRead: markReadEnabled, + }, + } as SendblueCapabilities; + + const requireCredentials = (): { readonly apiKey: string; readonly apiSecret: string } => { + if (apiKey === undefined || apiKey.length === 0) { + throw new AuthenticationError('A Sendblue API key is required.', { + provider: 'sendblue', + code: 'missing_api_key', + }); + } + if (apiSecret === undefined || apiSecret.length === 0) { + throw new AuthenticationError('A Sendblue API secret is required.', { + provider: 'sendblue', + code: 'missing_api_secret', + }); + } + return { apiKey, apiSecret }; + }; + + const requireFromNumber = (): string => { + if (fromNumber === undefined || fromNumber.length === 0) { + throw new ValidationError('A Sendblue from number is required.', { + provider: 'sendblue', + code: 'missing_from_number', + }); + } + return requirePhone(fromNumber, 'fromNumber'); + }; + + const request = async ( + path: string, + init: RequestInit = {}, + requestOptions: { readonly send?: boolean; readonly notFoundNull?: boolean } = {}, + ): Promise => { + const credentials = requireCredentials(); + const isFormData = typeof FormData !== 'undefined' && init.body instanceof FormData; + let response: Response; + try { + response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + accept: 'application/json', + 'sb-api-key-id': credentials.apiKey, + 'sb-api-secret-key': credentials.apiSecret, + ...(init.body === undefined || isFormData ? {} : { 'content-type': 'application/json' }), + ...init.headers, + }, + }); + } catch (cause) { + if (requestOptions.send === true) { + throw new AmbiguousDeliveryError( + 'The Sendblue send result is unknown; check the message status before retrying.', + { provider: 'sendblue', code: 'ambiguous_delivery', retryable: true, raw: cause }, + ); + } + throw new ProviderUnavailableError('Could not reach Sendblue.', { + provider: 'sendblue', + code: 'provider_unavailable', + retryable: true, + raw: cause, + }); + } + + let rawText: string; + try { + rawText = await response.text(); + } catch (cause) { + if (requestOptions.send === true) { + throw new AmbiguousDeliveryError( + 'The Sendblue send result is unknown; check the message status before retrying.', + { provider: 'sendblue', code: 'ambiguous_delivery', retryable: true, raw: cause }, + ); + } + throw new ProviderUnavailableError('Could not read the Sendblue response.', { + provider: 'sendblue', + code: 'provider_unavailable', + retryable: true, + raw: cause, + }); + } + let raw: unknown; + try { + raw = rawText.length === 0 ? undefined : JSON.parse(rawText); + } catch { + raw = rawText; + } + if (response.ok) return raw ?? {}; + if (response.status === 404 && requestOptions.notFoundNull === true) return null; + + const parsedError = ApiErrorSchema.safeParse(raw); + const body = parsedError.success ? parsedError.data : undefined; + const message = + body?.message ?? + body?.error_message ?? + body?.error ?? + `Sendblue request failed with HTTP ${response.status}.`; + const code = + body?.error_code === undefined || body.error_code === null + ? `http_${response.status}` + : String(body.error_code); + const traceId = response.headers.get('x-request-id') ?? response.headers.get('x-trace-id'); + const common = { + provider: 'sendblue', + code, + statusCode: response.status, + raw, + ...(traceId === null ? {} : { traceId }), + } as const; + + if (requestOptions.send === true && (response.status === 408 || response.status >= 500)) { + throw new AmbiguousDeliveryError( + 'The Sendblue send result is unknown; check the message status before retrying.', + { ...common, retryable: true }, + ); + } + + if (response.status === 401 || response.status === 403) { + throw new AuthenticationError(message, common); + } + if (response.status === 400 || response.status === 422) { + throw new ValidationError(message, common); + } + if (response.status === 404) throw new NotFoundError(message, common); + if (response.status === 409) throw new ConflictError(message, common); + if (response.status === 429) { + const retryAfterHeader = response.headers.get('retry-after'); + const retryAfter = retryAfterHeader === null ? undefined : Number(retryAfterHeader); + throw new RateLimitError(message, { + ...common, + retryable: true, + ...(retryAfter !== undefined && Number.isFinite(retryAfter) ? { retryAfter } : {}), + }); + } + if (response.status >= 500) { + throw new ProviderUnavailableError(message, { ...common, retryable: true }); + } + throw new IMessageSDKError(message, common); + }; + + const uploadAttachment = async (attachment: IMessageAttachmentInput): Promise => { + if (attachment.source.type === 'url') return requirePublicUrl(attachment.source.url); + const size = + attachment.source.type === 'blob' + ? attachment.source.data.size + : attachment.source.data.byteLength; + if (size > MAX_ATTACHMENT_BYTES) { + throw new ValidationError('Sendblue attachments must not exceed 100 MB.', { + provider: 'sendblue', + code: 'attachment_too_large', + raw: { size, maximum: MAX_ATTACHMENT_BYTES }, + }); + } + const body = new FormData(); + let blob: Blob; + if (attachment.source.type === 'blob') { + blob = + attachment.contentType === undefined || + attachment.source.data.type === attachment.contentType + ? attachment.source.data + : new Blob([await attachment.source.data.arrayBuffer()], { + type: attachment.contentType, + }); + } else { + const bytes = Uint8Array.from(attachment.source.data); + blob = new Blob([bytes.buffer], { + ...(attachment.contentType === undefined ? {} : { type: attachment.contentType }), + }); + } + body.append('file', blob, filenameForAttachment(attachment)); + const raw = await request('/api/upload-file', { method: 'POST', body }); + const parsed = UploadResponseSchema.safeParse(raw); + if (!parsed.success) { + throw new IMessageSDKError('Sendblue did not return an uploaded media URL.', { + provider: 'sendblue', + code: 'invalid_upload_response', + raw, + }); + } + return parsed.data.media_url; + }; + + const messages: SendblueMessages = { + async send(input): Promise { + if (input.replyTo !== undefined) { + throw new ValidationError('Sendblue does not document native message replies.', { + provider: 'sendblue', + code: 'replies_not_supported', + }); + } + if (input.idempotencyKey !== undefined) { + throw new ValidationError( + 'Sendblue does not document idempotency keys for message sends.', + { + provider: 'sendblue', + code: 'idempotency_not_supported', + }, + ); + } + if ((input.attachments?.length ?? 0) > 1) { + throw new ValidationError('Sendblue supports one attachment per direct message.', { + provider: 'sendblue', + code: 'too_many_attachments', + }); + } + if (input.text !== undefined && input.text.length > MAX_MESSAGE_LENGTH) { + throw new ValidationError( + `Sendblue message text must not exceed ${MAX_MESSAGE_LENGTH} characters.`, + { + provider: 'sendblue', + code: 'message_too_long', + }, + ); + } + const hasText = input.text !== undefined && input.text.trim().length > 0; + if (!hasText && (input.attachments?.length ?? 0) === 0) { + throw new ValidationError('A Sendblue message requires text or one attachment.', { + provider: 'sendblue', + code: 'message_content_required', + }); + } + const number = oneRecipient(input); + const sender = requireFromNumber(); + const attachment = input.attachments?.[0]; + const mediaUrl = attachment === undefined ? undefined : await uploadAttachment(attachment); + const raw = await request( + '/api/send-message', + { + method: 'POST', + body: JSON.stringify({ + number, + from_number: sender, + ...(hasText ? { content: input.text } : {}), + ...(mediaUrl === undefined ? {} : { media_url: mediaUrl }), + }), + }, + { send: true }, + ); + const parsed = parseMessage(raw); + if (parsed === undefined) { + throw new AmbiguousDeliveryError( + 'Sendblue accepted the request but did not return a trackable message handle.', + { + provider: 'sendblue', + code: 'ambiguous_delivery', + retryable: true, + raw, + }, + ); + } + const message = mapProviderMessage( + { ...parsed, is_outbound: true, number, from_number: sender }, + number, + sender, + raw, + ); + return { + ...message, + direction: 'outbound', + attachments: + attachment === undefined + ? message.attachments + : [ + { + kind: attachment.kind, + ...(mediaUrl === undefined ? {} : { url: mediaUrl }), + ...(attachment.filename === undefined ? {} : { filename: attachment.filename }), + ...(attachment.contentType === undefined + ? {} + : { contentType: attachment.contentType }), + raw: attachment, + }, + ], + }; + }, + + async get(message) { + const raw = await request( + `/api/v2/messages/${encodeURIComponent(message.messageId)}`, + {}, + { notFoundNull: true }, + ); + if (raw === null) return null; + const parsed = parseMessage(raw); + if (parsed === undefined) { + throw new IMessageSDKError('Sendblue returned an invalid message.', { + provider: 'sendblue', + code: 'invalid_provider_response', + raw, + }); + } + return mapProviderMessage(parsed, message.conversationId, fromNumber, raw); + }, + + async getStatus(message) { + const raw = await request( + `/api/v2/messages/${encodeURIComponent(message.messageId)}`, + {}, + { notFoundNull: true }, + ); + if (raw === null) return null; + const parsed = parseMessage(raw); + if (parsed === undefined || parsed.status === undefined) { + throw new IMessageSDKError('Sendblue returned an invalid message status.', { + provider: 'sendblue', + code: 'invalid_provider_response', + raw, + }); + } + const providerStatus = parsed.status; + const status = mapStatus(providerStatus); + const sentAt = parseDate(parsed.date_sent); + const deliveredAt = + parseDate(parsed.date_delivered) ?? + (providerStatus.toUpperCase() === 'DELIVERED' ? parseDate(parsed.date_updated) : undefined); + const error = parsed.error_message ?? parsed.error_detail ?? parsed.error_reason ?? undefined; + return { + messageId: parsed.message_handle, + conversationId: contactNumber(parsed, message.conversationId), + status, + providerStatus, + service: parsed.was_downgraded === true ? 'sms' : mapService(parsed.service), + ...(sentAt === undefined ? {} : { sentAt }), + ...(deliveredAt === undefined ? {} : { deliveredAt }), + ...(error === undefined ? {} : { error }), + raw, + }; + }, + }; + + const openConversation = async (input: OpenConversationInput): Promise => { + if (input.participants.length !== 1 || input.participants[0]?.kind !== 'phone') { + throw new ValidationError('Sendblue supports direct phone conversations only.', { + provider: 'sendblue', + code: 'direct_phone_conversation_required', + }); + } + const number = requirePhone(input.participants[0].value, 'participants[0]'); + return { + providerConversationId: number, + participants: [address(number)], + raw: { number, resolved: false }, + }; + }; + + const markRead = async (conversationId: string): Promise => { + const raw = await request('/api/mark-read', { + method: 'POST', + body: JSON.stringify({ + number: requirePhone(conversationId, 'conversationId'), + from_number: requireFromNumber(), + }), + }); + assertOperationSucceeded(raw, 'mark-read request'); + }; + + const conversations = { + open: openConversation, + ...(markReadEnabled ? { markRead } : {}), + } as SendblueConversations; + + const typing = async (conversationId: string, state: 'start' | 'stop'): Promise => { + const raw = await request('/api/send-typing-indicator', { + method: 'POST', + body: JSON.stringify({ + number: requirePhone(conversationId, 'conversationId'), + from_number: requireFromNumber(), + state, + }), + }); + assertOperationSucceeded(raw, 'typing indicator'); + }; + + return defineProvider({ + name: 'sendblue', + capabilities, + messages, + conversations, + typing: { + async start(conversationId) { + await typing(conversationId, 'start'); + }, + async stop(conversationId) { + await typing(conversationId, 'stop'); + }, + }, + webhooks: { + async verify(webhookRequest) { + if (webhookSecret === undefined || webhookSecret.length === 0) return false; + const supplied = webhookRequest.headers.get('sb-signing-secret'); + return supplied !== null && constantTimeEqual(webhookSecret, supplied); + }, + async parse(webhookRequest) { + let body: unknown; + try { + body = await webhookRequest.json(); + } catch { + return []; + } + const configuredLine = requireFromNumber(); + const values = Array.isArray(body) ? body : [body]; + return values.flatMap((value): ProviderEvent[] => { + const typingEvent = TypingWebhookSchema.safeParse(value); + if (typingEvent.success) { + const event = typingEvent.data; + if (event.from_number !== configuredLine) return []; + const timestamp = parseDate(event.timestamp) ?? new Date(); + return [ + { + id: `sendblue:typing:${event.number}:${event.is_typing ? 'start' : 'stop'}:${timestamp.valueOf()}`, + type: event.is_typing ? 'typing.started' : 'typing.stopped', + timestamp, + conversationId: event.number, + actor: address(event.number), + raw: value, + }, + ]; + } + const message = parseMessage(value); + if (message === undefined) return []; + const line = webhookLine(message); + if (line !== configuredLine) return []; + const event = mapWebhookMessage(message, value); + return event === undefined ? [] : [event]; + }); + }, + }, + tapbacks: { + async add(input) { + requirePhone(input.conversationId, 'conversationId'); + if ( + input.partIndex !== undefined && + (!Number.isInteger(input.partIndex) || input.partIndex < 0) + ) { + throw new ValidationError('Sendblue tapback partIndex must be a non-negative integer.', { + provider: 'sendblue', + code: 'invalid_part_index', + }); + } + const raw = await request('/api/send-reaction', { + method: 'POST', + body: JSON.stringify({ + from_number: requireFromNumber(), + message_handle: input.messageId, + reaction: input.reaction, + ...(input.partIndex === undefined ? {} : { part_index: input.partIndex }), + }), + }); + assertOperationSucceeded(raw, 'tapback'); + }, + }, + }); +} diff --git a/packages/providers/sendblue/test/sendblue.integration.test.ts b/packages/providers/sendblue/test/sendblue.integration.test.ts new file mode 100644 index 0000000..7ee4705 --- /dev/null +++ b/packages/providers/sendblue/test/sendblue.integration.test.ts @@ -0,0 +1,295 @@ +import process from 'node:process'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import type { MessageLocator } from 'imessage-sdk'; +import { createIMessageClient, IMessageSDKError } from 'imessage-sdk'; + +import type { SendblueMessageStatus } from '../src/index.js'; +import { sendblue } from '../src/index.js'; + +const enabled = process.env['SENDBLUE_LIVE_TEST'] === '1'; +const markReadEnabled = process.env['SENDBLUE_TEST_MARK_READ'] === '1'; +const sendblueApiBaseUrl = 'https://api.sendblue.com'; + +const MessageListSchema = z + .object({ + data: z.array( + z + .object({ + is_outbound: z.boolean().optional(), + message_handle: z.string().min(1).optional(), + message_type: z.string().optional(), + number: z.string().optional(), + sendblue_number: z.string().nullable().optional(), + service: z.string().optional(), + }) + .loose(), + ), + }) + .loose(); + +describe.skipIf(!enabled)('Sendblue live API', () => { + it('exercises the Sendblue v0.1 inbound and outbound operations', async () => { + const apiKey = required('SENDBLUE_API_KEY'); + const apiSecret = required('SENDBLUE_API_SECRET'); + const fromNumber = required('SENDBLUE_FROM_NUMBER'); + const recipientNumber = required('SENDBLUE_TEST_RECIPIENT'); + const imageUrl = required('SENDBLUE_TEST_IMAGE_URL'); + const videoUrl = required('SENDBLUE_TEST_VIDEO_URL'); + const fileUrl = required('SENDBLUE_TEST_FILE_URL'); + const messageHandle = await waitForLatestInboundMessageHandle({ + apiKey, + apiSecret, + fromNumber, + recipientNumber, + }); + const provider = sendblue({ markReadEnabled }); + const client = createIMessageClient({ + connectionId: 'sendblue-live', + provider, + }); + const recipient = { kind: 'phone', value: recipientNumber } as const; + const run = String(Date.now()); + + await provider.tapbacks.add({ + conversationId: recipientNumber, + messageId: messageHandle, + reaction: 'like', + }); + + const conversation = await client.conversations.open({ + participants: [recipient], + }); + if (markReadEnabled) { + await client.conversations.markRead(conversation.id); + } + const text = await client.messages.send({ + conversationId: conversation.id, + text: `imessage-sdk Sendblue live test ${run}`, + }); + const imageResponse = await download(imageUrl); + const fileResponse = await download(fileUrl); + const attachments = []; + for (const [kind, source, filename, contentType] of [ + [ + 'image', + { type: 'blob', data: await imageResponse.blob() }, + filenameFromUrl(imageUrl, 'image'), + imageResponse.headers.get('content-type') ?? undefined, + ], + ['video', { type: 'url', url: videoUrl }, undefined, undefined], + [ + 'file', + { type: 'bytes', data: new Uint8Array(await fileResponse.arrayBuffer()) }, + filenameFromUrl(fileUrl, 'file'), + fileResponse.headers.get('content-type') ?? undefined, + ], + ] as const) { + attachments.push( + await client.messages.send({ + conversationId: conversation.id, + text: `${kind} attachment source test`, + attachments: [ + { + kind, + source, + ...(filename === undefined ? {} : { filename }), + ...(contentType === undefined ? {} : { contentType }), + }, + ], + }), + ); + } + const locator = { + conversationId: conversation.id, + messageId: text.providerMessageId, + }; + + const found = await client.messages.get(locator); + const status = await getStatusWithDiagnostics(provider, locator); + await client.typing.start(conversation.id); + await delay(2_000); + await client.typing.stop(conversation.id); + + expect(conversation.providerConversationId).toBe(recipientNumber); + expect(text.providerMessageId).toBeTruthy(); + expect(attachments).toHaveLength(3); + for (const attachment of attachments) { + expect(attachment.providerMessageId).toBeTruthy(); + expect(attachment.attachments).toHaveLength(1); + } + expect(found?.providerMessageId).toBe(text.providerMessageId); + expect(status?.messageId).toBe(text.providerMessageId); + expect(text.sender.value).toBe(fromNumber); + }, 180_000); +}); + +function required(name: string): string { + const value = process.env[name]; + if (value === undefined || value.length === 0) { + throw new Error(`${name} is required when SENDBLUE_LIVE_TEST=1.`); + } + return value; +} + +async function delay(milliseconds: number): Promise { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function download(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Could not download Sendblue live-test fixture: HTTP ${response.status}.`); + } + return response; +} + +function filenameFromUrl(url: string, fallback: string): string { + const pathname = new URL(url).pathname; + return pathname.split('/').filter(Boolean).at(-1) ?? fallback; +} + +interface LatestInboundMessageInput { + readonly apiKey: string; + readonly apiSecret: string; + readonly fromNumber: string; + readonly recipientNumber: string; +} + +async function waitForLatestInboundMessageHandle( + input: LatestInboundMessageInput, +): Promise { + const deadline = Date.now() + 30_000; + + do { + const handle = await getLatestInboundMessageHandle(input); + if (handle !== undefined) return handle; + await delay(1_000); + } while (Date.now() < deadline); + + throw new Error( + 'No inbound Sendblue message was found. Send a fresh iMessage to the configured Sendblue line, then rerun the integration test.', + ); +} + +async function getLatestInboundMessageHandle( + input: LatestInboundMessageInput, +): Promise { + const url = new URL('/api/v2/messages', sendblueApiBaseUrl); + url.search = new URLSearchParams({ + created_at_gte: new Date(Date.now() - 5 * 60_000).toISOString(), + is_outbound: 'false', + limit: '1', + message_type: 'message', + number: input.recipientNumber, + order_by: 'createdAt', + order_direction: 'desc', + sendblue_number: input.fromNumber, + service: 'iMessage', + }).toString(); + const response = await fetch(url, { + headers: { + accept: 'application/json', + 'sb-api-key-id': input.apiKey, + 'sb-api-secret-key': input.apiSecret, + }, + }); + + if (!response.ok) { + throw new Error(`Could not list inbound Sendblue messages: HTTP ${response.status}.`); + } + + const parsed = MessageListSchema.safeParse(await response.json()); + if (!parsed.success) { + throw new Error('Sendblue returned an invalid inbound message list.'); + } + + return parsed.data.data.find( + (message) => + message.is_outbound === false && + message.message_type === 'message' && + message.number === input.recipientNumber && + message.sendblue_number === input.fromNumber && + message.service?.toLowerCase() === 'imessage' && + message.message_handle !== undefined, + )?.message_handle; +} + +interface SendblueStatusReader { + readonly messages: { + getStatus(message: MessageLocator): Promise; + }; +} + +async function getStatusWithDiagnostics( + provider: SendblueStatusReader, + locator: MessageLocator, +): Promise { + try { + return await provider.messages.getStatus(locator); + } catch (error) { + const diagnostic = + error instanceof IMessageSDKError + ? { + name: error.name, + message: redactSensitiveValues(error.message), + code: error.code, + statusCode: error.statusCode, + retryable: error.retryable, + raw: sanitizeDiagnostic(error.raw), + } + : { + name: error instanceof Error ? error.name : typeof error, + message: + error instanceof Error + ? redactSensitiveValues(error.message) + : 'Non-Error value thrown.', + raw: sanitizeDiagnostic(error), + }; + + throw new Error( + `Sendblue status lookup failed for handle ${locator.messageId}. Sanitized diagnostic:\n${JSON.stringify(diagnostic, null, 2)}`, + { cause: error }, + ); + } +} + +function sanitizeDiagnostic(value: unknown, depth = 0): unknown { + if (depth >= 6) return '[MAX_DEPTH]'; + if (Array.isArray(value)) { + return value.map((item) => sanitizeDiagnostic(item, depth + 1)); + } + if (typeof value === 'string') return redactSensitiveValues(value); + if (value === null || typeof value !== 'object') return value; + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + isSensitiveDiagnosticKey(key) ? '[REDACTED]' : sanitizeDiagnostic(item, depth + 1), + ]), + ); +} + +function isSensitiveDiagnosticKey(key: string): boolean { + return /api.?key|secret|authorization|token|credential|password|account.?email|content|media.?url|from.?number|to.?number|sendblue.?number|^number$/iu.test( + key, + ); +} + +function redactSensitiveValues(value: string): string { + let result = value; + for (const sensitiveValue of [ + process.env['SENDBLUE_API_KEY'], + process.env['SENDBLUE_API_SECRET'], + process.env['SENDBLUE_WEBHOOK_SECRET'], + process.env['SENDBLUE_FROM_NUMBER'], + process.env['SENDBLUE_TEST_RECIPIENT'], + ]) { + if (sensitiveValue !== undefined && sensitiveValue.length > 0) { + result = result.split(sensitiveValue).join('[REDACTED]'); + } + } + return result; +} diff --git a/packages/providers/sendblue/test/sendblue.test.ts b/packages/providers/sendblue/test/sendblue.test.ts new file mode 100644 index 0000000..86c26ab --- /dev/null +++ b/packages/providers/sendblue/test/sendblue.test.ts @@ -0,0 +1,791 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import { + AmbiguousDeliveryError, + createIMessageClient, + RateLimitError, + ValidationError, + WebhookVerificationError, +} from 'imessage-sdk'; + +import type { SendblueOptions, SendblueProvider } from '../src/index.js'; +import { sendblue } from '../src/index.js'; + +const fromNumber = '+15550000000'; +const recipientNumber = '+15551111111'; +const recipient = { kind: 'phone', value: recipientNumber } as const; +const apiBaseUrl = 'https://sendblue.test'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +function messageResponse(overrides: Readonly> = {}) { + return { + message_handle: 'sendblue-message-1', + content: 'Hello', + from_number: fromNumber, + to_number: recipientNumber, + number: recipientNumber, + status: 'ACCEPTED', + service: 'iMessage', + is_outbound: true, + date_sent: '2026-07-15T12:00:00.000Z', + date_updated: '2026-07-15T12:00:01.000Z', + media_url: '', + message_type: 'message', + group_id: '', + participants: [], + ...overrides, + }; +} + +function configuredProvider(): SendblueProvider; +function configuredProvider(options: SendblueOptions): SendblueProvider; +function configuredProvider(options: SendblueOptions): SendblueProvider; +function configuredProvider( + options: SendblueOptions | SendblueOptions = {}, +): SendblueProvider { + const common = { + apiKey: 'test-key', + apiSecret: 'test-secret', + fromNumber, + baseUrl: apiBaseUrl, + ...options, + }; + return options.markReadEnabled === true + ? sendblue({ ...common, markReadEnabled: true }) + : sendblue({ ...common, markReadEnabled: false }); +} + +describe('Sendblue provider', () => { + it('preserves its literal name and capability types', () => { + const provider = configuredProvider(); + const markReadProvider = configuredProvider({ markReadEnabled: true }); + const dynamicMarkRead = Math.random() > 0.5; + const dynamicProvider = sendblue({ markReadEnabled: dynamicMarkRead }); + + expectTypeOf(provider.name).toEqualTypeOf<'sendblue'>(); + expectTypeOf(provider.capabilities.messages.attachments).toEqualTypeOf(); + expectTypeOf(provider.capabilities.messages.replies).toEqualTypeOf(); + expectTypeOf(provider.capabilities.conversations.groups).toEqualTypeOf(); + expectTypeOf(provider.capabilities.conversations.markRead).toEqualTypeOf(); + expectTypeOf(provider.capabilities.interactions.reactions).toEqualTypeOf(); + expectTypeOf(provider.capabilities.interactions.typingStart).toEqualTypeOf(); + expectTypeOf(provider.capabilities.interactions.typingStop).toEqualTypeOf(); + expectTypeOf(provider.capabilities.interactions.readReceipts).toEqualTypeOf(); + expectTypeOf(provider.capabilities.events.webhooks).toEqualTypeOf(); + expectTypeOf(provider.capabilities.events.stream).toEqualTypeOf(); + expectTypeOf(markReadProvider.capabilities.conversations.markRead).toEqualTypeOf(); + expectTypeOf(markReadProvider.capabilities.interactions.readReceipts).toEqualTypeOf(); + expectTypeOf(dynamicProvider.capabilities.conversations.markRead).toEqualTypeOf(); + expectTypeOf(provider.tapbacks.add).toBeFunction(); + + // @ts-expect-error A true capability requires an explicit true runtime option. + const missingFlag: SendblueOptions = {}; + void missingFlag; + }); + + it('reads credentials and the sending line from the environment', async () => { + vi.stubEnv('SENDBLUE_API_KEY', 'environment-key'); + vi.stubEnv('SENDBLUE_API_SECRET', 'environment-secret'); + vi.stubEnv('SENDBLUE_FROM_NUMBER', fromNumber); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse(messageResponse(), 202); + }); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ + provider: sendblue({ baseUrl: apiBaseUrl }), + }); + + await client.messages.send({ to: recipient, text: 'Environment' }); + + expect(fetchMock).toHaveBeenCalledWith( + `${apiBaseUrl}/api/send-message`, + expect.objectContaining({ + headers: expect.objectContaining({ + 'sb-api-key-id': 'environment-key', + 'sb-api-secret-key': 'environment-secret', + }), + }), + ); + }); + + it('sends text and one public URL attachment', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse( + messageResponse({ + content: 'Photo', + media_url: 'https://cdn.test/photo.jpg', + }), + 202, + ); + }); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ + connectionId: 'sendblue-line', + provider: configuredProvider(), + }); + + const message = await client.messages.send({ + conversationId: recipientNumber, + text: 'Photo', + attachments: [ + { + kind: 'image', + source: { type: 'url', url: 'https://cdn.test/photo.jpg' }, + filename: 'photo.jpg', + contentType: 'image/jpeg', + }, + ], + }); + + expect(message).toMatchObject({ + id: 'sendblue-message-1', + provider: 'sendblue', + connectionId: 'sendblue-line', + conversationId: recipientNumber, + direction: 'outbound', + status: 'accepted', + providerStatus: 'ACCEPTED', + attachments: [{ kind: 'image', url: 'https://cdn.test/photo.jpg' }], + }); + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(url).toBe(`${apiBaseUrl}/api/send-message`); + expect(init).toMatchObject({ + method: 'POST', + headers: expect.objectContaining({ + 'content-type': 'application/json', + 'sb-api-key-id': 'test-key', + 'sb-api-secret-key': 'test-secret', + }), + }); + expect(JSON.parse(String(init?.body))).toEqual({ + number: recipientNumber, + from_number: fromNumber, + content: 'Photo', + media_url: 'https://cdn.test/photo.jpg', + }); + }); + + it.each([ + { + name: 'Blob', + source: { + type: 'blob' as const, + data: new Blob([new Uint8Array([1, 2, 3])]), + }, + filename: 'photo.png', + contentType: 'image/png', + }, + { + name: 'Uint8Array', + source: { type: 'bytes' as const, data: new Uint8Array([4, 5, 6]) }, + filename: 'document.pdf', + contentType: 'application/pdf', + }, + ])('uploads a $name attachment before sending it', async (attachment) => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ media_url: `https://cdn.test/${attachment.filename}` })) + .mockResolvedValueOnce( + jsonResponse( + messageResponse({ + content: '', + media_url: `https://cdn.test/${attachment.filename}`, + }), + 202, + ), + ); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ provider: configuredProvider() }); + + await client.messages.send({ + to: recipient, + attachments: [ + { + kind: attachment.contentType.startsWith('image/') ? 'image' : 'file', + source: attachment.source, + filename: attachment.filename, + contentType: attachment.contentType, + }, + ], + }); + + const [uploadUrl, uploadInit] = fetchMock.mock.calls[0] ?? []; + expect(uploadUrl).toBe(`${apiBaseUrl}/api/upload-file`); + expect(uploadInit?.method).toBe('POST'); + expect(uploadInit?.headers).toEqual( + expect.objectContaining({ + 'sb-api-key-id': 'test-key', + 'sb-api-secret-key': 'test-secret', + }), + ); + expect(uploadInit?.headers).not.toEqual( + expect.objectContaining({ 'content-type': 'application/json' }), + ); + expect(uploadInit?.body).toBeInstanceOf(FormData); + const formData = uploadInit?.body as FormData; + const uploadedFile = formData.get('file'); + expect(uploadedFile).toBeInstanceOf(Blob); + expect(uploadedFile).toMatchObject({ + name: attachment.filename, + type: attachment.contentType, + size: 3, + }); + + const [sendUrl, sendInit] = fetchMock.mock.calls[1] ?? []; + expect(sendUrl).toBe(`${apiBaseUrl}/api/send-message`); + expect(JSON.parse(String(sendInit?.body))).toEqual({ + number: recipientNumber, + from_number: fromNumber, + media_url: `https://cdn.test/${attachment.filename}`, + }); + }); + + it('rejects multiple attachments, replies, groups, and idempotency keys', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse(messageResponse(), 202); + }); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ provider: configuredProvider() }); + const attachment = { + kind: 'image' as const, + source: { type: 'url' as const, url: 'https://cdn.test/photo.jpg' }, + }; + + await expect( + client.messages.send({ + to: recipient, + attachments: [attachment, attachment], + }), + ).rejects.toBeInstanceOf(ValidationError); + await expect( + client.messages.send({ + to: recipient, + text: 'Reply', + replyTo: { messageId: 'parent-message' }, + }), + ).rejects.toMatchObject({ + name: 'UnsupportedCapabilityError', + capability: 'messages.replies', + }); + await expect( + client.messages.send({ + to: [recipient, { kind: 'phone', value: '+15552222222' }], + text: 'Group', + }), + ).rejects.toMatchObject({ + name: 'UnsupportedCapabilityError', + capability: 'conversations.groups', + }); + await expect( + client.messages.send({ + to: recipient, + text: 'No idempotency support', + idempotencyKey: 'operation-1', + }), + ).rejects.toBeInstanceOf(ValidationError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('validates attachment URLs, upload size, text length, and empty content locally', async () => { + const fetchMock = vi.fn(async () => jsonResponse(messageResponse(), 202)); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ provider: configuredProvider() }); + const oversized = new Blob([]); + Object.defineProperty(oversized, 'size', { value: 100 * 1024 * 1024 + 1 }); + + await expect( + client.messages.send({ + to: recipient, + attachments: [{ kind: 'file', source: { type: 'url', url: 'file:///secret.txt' } }], + }), + ).rejects.toMatchObject({ code: 'invalid_attachment_url' }); + await expect( + client.messages.send({ + to: recipient, + attachments: [{ kind: 'file', source: { type: 'blob', data: oversized } }], + }), + ).rejects.toMatchObject({ code: 'attachment_too_large' }); + await expect( + client.messages.send({ to: recipient, text: 'x'.repeat(18_997) }), + ).rejects.toMatchObject({ code: 'message_too_long' }); + await expect(client.messages.send({ to: recipient, text: ' ' })).rejects.toMatchObject({ + code: 'message_content_required', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('gets a message and its current status', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse( + messageResponse({ + status: 'DELIVERED', + date_delivered: '2026-07-15T12:00:02.000Z', + }), + ), + ) + .mockResolvedValueOnce( + jsonResponse( + messageResponse({ + status: 'READ', + date_read: '2026-07-15T12:00:03.000Z', + was_downgraded: true, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ provider: configuredProvider() }); + const locator = { + conversationId: recipientNumber, + messageId: 'sendblue-message-1', + }; + + const message = await client.messages.get(locator); + const status = await client.providers.sendblue.messages.getStatus(locator); + + expect(message).toMatchObject({ + providerMessageId: 'sendblue-message-1', + conversationId: recipientNumber, + status: 'delivered', + service: 'imessage', + }); + expect(status).toMatchObject({ + messageId: 'sendblue-message-1', + conversationId: recipientNumber, + status: 'read', + providerStatus: 'READ', + service: 'sms', + }); + expect(fetchMock.mock.calls[0]?.[0]).toBe(`${apiBaseUrl}/api/v2/messages/sendblue-message-1`); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`${apiBaseUrl}/api/v2/messages/sendblue-message-1`); + }); + + it('rejects a status response without a provider status', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + jsonResponse({ + message_handle: 'sendblue-message-1', + number: recipientNumber, + }), + ), + ); + const provider = configuredProvider(); + + await expect( + provider.messages.getStatus({ + conversationId: recipientNumber, + messageId: 'sendblue-message-1', + }), + ).rejects.toMatchObject({ + name: 'IMessageSDKError', + code: 'invalid_provider_response', + }); + }); + + it('maps an uncertain send result to AmbiguousDeliveryError', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Promise.reject(new TypeError('network unavailable'))), + ); + const client = createIMessageClient({ provider: configuredProvider() }); + + await expect( + client.messages.send({ to: recipient, text: 'Maybe accepted' }), + ).rejects.toMatchObject({ + name: AmbiguousDeliveryError.name, + provider: 'sendblue', + code: 'ambiguous_delivery', + retryable: true, + }); + }); + + it.each([408, 500, 503])( + 'maps an uncertain send HTTP %s response to AmbiguousDeliveryError', + async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + jsonResponse({ status: 'ERROR', error_message: 'Upstream failure' }, status), + ), + ); + const client = createIMessageClient({ provider: configuredProvider() }); + + await expect( + client.messages.send({ to: recipient, text: 'Maybe accepted' }), + ).rejects.toMatchObject({ + name: AmbiguousDeliveryError.name, + provider: 'sendblue', + code: `http_${status}`, + statusCode: status, + retryable: true, + }); + }, + ); + + it('maps a successful send without a message handle to AmbiguousDeliveryError', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse({ status: 'ACCEPTED' }, 202)), + ); + const client = createIMessageClient({ provider: configuredProvider() }); + + await expect( + client.messages.send({ to: recipient, text: 'Accepted without an ID' }), + ).rejects.toMatchObject({ + name: AmbiguousDeliveryError.name, + provider: 'sendblue', + code: 'ambiguous_delivery', + retryable: true, + }); + }); + + it('maps a send response-body failure to AmbiguousDeliveryError', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + ({ + ok: true, + status: 202, + headers: new Headers(), + text: async () => Promise.reject(new TypeError('connection closed')), + }) as Response, + ), + ); + const client = createIMessageClient({ provider: configuredProvider() }); + + await expect( + client.messages.send({ to: recipient, text: 'Maybe accepted' }), + ).rejects.toMatchObject({ + name: AmbiguousDeliveryError.name, + code: 'ambiguous_delivery', + retryable: true, + }); + }); + + it('maps HTTP 429 responses and retry-after metadata', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + jsonResponse({ status: 'ERROR', error_message: 'Too many requests' }, 429, { + 'retry-after': '12', + }), + ), + ); + const client = createIMessageClient({ provider: configuredProvider() }); + + await expect( + client.messages.get({ + conversationId: recipientNumber, + messageId: 'sendblue-message-1', + }), + ).rejects.toMatchObject({ + name: RateLimitError.name, + provider: 'sendblue', + statusCode: 429, + retryable: true, + retryAfter: 12, + }); + }); + + it('starts and stops typing and exposes add-only tapbacks', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse({ status: 'SENT' }); + }); + vi.stubGlobal('fetch', fetchMock); + const client = createIMessageClient({ provider: configuredProvider() }); + const locator = { + conversationId: recipientNumber, + messageId: 'sendblue-message-1', + }; + + await client.typing.start(recipientNumber); + await client.typing.stop(recipientNumber); + await client.providers.sendblue.tapbacks.add({ + ...locator, + reaction: 'like', + partIndex: 1, + }); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + `${apiBaseUrl}/api/send-typing-indicator`, + `${apiBaseUrl}/api/send-typing-indicator`, + `${apiBaseUrl}/api/send-reaction`, + ]); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + number: recipientNumber, + from_number: fromNumber, + state: 'start', + }); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({ + number: recipientNumber, + from_number: fromNumber, + state: 'stop', + }); + expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({ + from_number: fromNumber, + message_handle: 'sendblue-message-1', + reaction: 'like', + part_index: 1, + }); + await expect(client.reactions.add({ ...locator, reaction: 'like' })).rejects.toMatchObject({ + name: 'UnsupportedCapabilityError', + capability: 'reactions.add', + }); + await expect( + client.providers.sendblue.tapbacks.add({ + ...locator, + reaction: 'like', + partIndex: -1, + }), + ).rejects.toMatchObject({ code: 'invalid_part_index' }); + }); + + it('does not treat a 2xx operation-level error as success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse({ status: 'ERROR', error_message: 'No route mapping found' })), + ); + const client = createIMessageClient({ provider: configuredProvider() }); + + await expect(client.typing.start(recipientNumber)).rejects.toMatchObject({ + name: 'IMessageSDKError', + code: 'sendblue_operation_failed', + raw: { status: 'ERROR', error_message: 'No route mapping found' }, + }); + }); + + it('keeps mark-read disabled by default and enables it explicitly', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return jsonResponse({ status: 'SENT' }); + }); + vi.stubGlobal('fetch', fetchMock); + const disabledClient = createIMessageClient({ provider: configuredProvider() }); + + await expect(disabledClient.conversations.markRead(recipientNumber)).rejects.toMatchObject({ + name: 'UnsupportedCapabilityError', + capability: 'conversations.markRead', + }); + expect(fetchMock).not.toHaveBeenCalled(); + + const enabledClient = createIMessageClient({ + provider: configuredProvider({ markReadEnabled: true }), + }); + await enabledClient.conversations.markRead(recipientNumber); + + expect(fetchMock).toHaveBeenCalledWith( + `${apiBaseUrl}/api/mark-read`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + number: recipientNumber, + from_number: fromNumber, + }), + }), + ); + }); + + it('verifies the webhook secret and filters events for other configured lines', async () => { + const provider = configuredProvider({ webhookSecret: 'webhook-secret' }); + const client = createIMessageClient({ provider }); + const inbound = messageResponse({ + message_handle: 'inbound-photo', + content: '', + from_number: recipientNumber, + to_number: fromNumber, + sendblue_number: fromNumber, + is_outbound: false, + status: 'RECEIVED', + media_url: 'https://cdn.test/inbound.jpg', + }); + + await expect( + client.webhooks.handle(webhookRequest(inbound, 'wrong-secret')), + ).rejects.toBeInstanceOf(WebhookVerificationError); + + const events = await client.webhooks.handle(webhookRequest(inbound)); + const ignored = await client.webhooks.handle( + webhookRequest({ ...inbound, sendblue_number: '+15553333333' }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'message.received', + provider: 'sendblue', + message: { + providerMessageId: 'inbound-photo', + conversationId: recipientNumber, + direction: 'inbound', + sender: recipient, + recipients: [{ kind: 'phone', value: fromNumber }], + text: '', + status: 'delivered', + service: 'imessage', + attachments: [ + { + kind: 'image', + url: 'https://cdn.test/inbound.jpg', + }, + ], + }, + raw: inbound, + }); + expect(ignored).toEqual([]); + + await expect( + client.webhooks.handle( + webhookRequest({ + ...inbound, + sendblue_number: null, + to_number: null, + }), + ), + ).resolves.toEqual([]); + }); + + it('requires a configured line before normalizing account-wide webhooks', async () => { + const client = createIMessageClient({ + provider: sendblue({ + apiKey: 'test-key', + apiSecret: 'test-secret', + webhookSecret: 'webhook-secret', + baseUrl: apiBaseUrl, + }), + }); + + await expect(client.webhooks.handle(webhookRequest(messageResponse()))).rejects.toMatchObject({ + name: ValidationError.name, + code: 'missing_from_number', + }); + }); + + it.each([ + ['ACCEPTED', 'message.sent', 'accepted'], + ['SENT', 'message.sent', 'sent'], + ['DELIVERED', 'message.delivered', 'delivered'], + ['ERROR', 'message.failed', 'failed'], + ] as const)('maps outbound %s webhooks', async (status, eventType, messageStatus) => { + const client = createIMessageClient({ + provider: configuredProvider({ webhookSecret: 'webhook-secret' }), + }); + const events = await client.webhooks.handle( + webhookRequest(messageResponse({ status, is_outbound: true })), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: eventType, + message: { + direction: 'outbound', + status: messageStatus, + providerStatus: status, + }, + }); + }); + + it('ignores undocumented outbound read-receipt webhooks', async () => { + const client = createIMessageClient({ + provider: configuredProvider({ webhookSecret: 'webhook-secret' }), + }); + + await expect( + client.webhooks.handle( + webhookRequest(messageResponse({ status: 'READ', is_outbound: true })), + ), + ).resolves.toEqual([]); + }); + + it('normalizes typing-started and typing-stopped webhooks', async () => { + const client = createIMessageClient({ + provider: configuredProvider({ webhookSecret: 'webhook-secret' }), + }); + + const started = await client.webhooks.handle( + webhookRequest({ + number: recipientNumber, + from_number: fromNumber, + is_typing: true, + timestamp: '2026-07-15T12:00:00.000Z', + }), + ); + const stopped = await client.webhooks.handle( + webhookRequest({ + number: recipientNumber, + from_number: fromNumber, + is_typing: false, + timestamp: '2026-07-15T12:00:01.000Z', + }), + ); + + expect(started[0]).toMatchObject({ + type: 'typing.started', + conversationId: recipientNumber, + actor: recipient, + }); + expect(stopped[0]).toMatchObject({ + type: 'typing.stopped', + conversationId: recipientNumber, + actor: recipient, + }); + }); + + it('ignores malformed and unsupported webhook payloads', async () => { + const client = createIMessageClient({ + provider: configuredProvider({ webhookSecret: 'webhook-secret' }), + }); + + await expect(client.webhooks.handle(webhookRequest({}))).resolves.toEqual([]); + await expect( + client.webhooks.handle( + webhookRequest({ + message_handle: 42, + is_outbound: 'no', + is_typing: 'sometimes', + }), + ), + ).resolves.toEqual([]); + await expect( + client.webhooks.handle( + new Request('https://example.test/sendblue', { + method: 'POST', + headers: { 'sb-signing-secret': 'webhook-secret' }, + body: '{not-json', + }), + ), + ).resolves.toEqual([]); + }); +}); + +function webhookRequest(body: unknown, secret = 'webhook-secret'): Request { + return new Request('https://example.test/sendblue', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'sb-signing-secret': secret, + }, + body: JSON.stringify(body), + }); +} diff --git a/packages/providers/sendblue/tsconfig.json b/packages/providers/sendblue/tsconfig.json new file mode 100644 index 0000000..b61eac9 --- /dev/null +++ b/packages/providers/sendblue/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { "types": ["node"] }, + "include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts"] +} diff --git a/packages/providers/sendblue/tsup.config.ts b/packages/providers/sendblue/tsup.config.ts new file mode 100644 index 0000000..150d7e8 --- /dev/null +++ b/packages/providers/sendblue/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { index: 'src/index.ts' }, + format: ['esm'], + target: 'es2022', + platform: 'neutral', + dts: true, + clean: true, + sourcemap: true, + splitting: false, + treeshake: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a04a56e..91b61bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,12 +94,6 @@ importers: specifier: workspace:^ version: link:../imessage-sdk - packages/eve-channel: - dependencies: - imessage-sdk: - specifier: workspace:^ - version: link:../imessage-sdk - packages/imessage-sdk: {} packages/providers/blooio: @@ -126,6 +120,15 @@ importers: specifier: ^4.4.3 version: 4.4.3 + packages/providers/sendblue: + dependencies: + imessage-sdk: + specifier: workspace:^ + version: link:../../imessage-sdk + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages: '@andrewbranch/untar.js@1.0.3': diff --git a/scripts/check-packages.sh b/scripts/check-packages.sh index 7056419..3360e8f 100644 --- a/scripts/check-packages.sh +++ b/scripts/check-packages.sh @@ -13,6 +13,7 @@ trap cleanup EXIT pnpm --filter imessage-sdk pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/blooio pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/photon pack --pack-destination "${PACKAGE_DIR}" +pnpm --filter @imessage-sdk/sendblue pack --pack-destination "${PACKAGE_DIR}" pnpm --filter @imessage-sdk/chat-adapter pack --pack-destination "${PACKAGE_DIR}" TARBALLS=("${PACKAGE_DIR}"/*.tgz) @@ -34,6 +35,7 @@ pnpm exec tsc --project "${CONSUMER_DIR}/tsconfig.json" await import("imessage-sdk"); await import("@imessage-sdk/blooio"); await import("@imessage-sdk/photon"); + await import("@imessage-sdk/sendblue"); await import("@imessage-sdk/chat-adapter"); await import("chat"); ' diff --git a/test/package-consumer/index.ts b/test/package-consumer/index.ts index 1b1c8c0..be7f072 100644 --- a/test/package-consumer/index.ts +++ b/test/package-consumer/index.ts @@ -3,6 +3,7 @@ import type { Adapter } from 'chat'; import { blooio } from '@imessage-sdk/blooio'; import { createIMessageAdapter } from '@imessage-sdk/chat-adapter'; import { photon } from '@imessage-sdk/photon'; +import { sendblue } from '@imessage-sdk/sendblue'; import { createIMessageClient } from 'imessage-sdk'; const blooioClient = createIMessageClient({ @@ -15,26 +16,65 @@ const photonClient = createIMessageClient({ provider: photon({ projectId: 'test', projectSecret: 'test' }), }); +const sendblueClient = createIMessageClient({ + connectionId: 'sendblue-consumer', + provider: sendblue({ apiKey: 'test', apiSecret: 'test', fromNumber: '+15555550100' }), +}); + +const sendblueMarkReadClient = createIMessageClient({ + connectionId: 'sendblue-mark-read-consumer', + provider: sendblue({ + apiKey: 'test', + apiSecret: 'test', + fromNumber: '+15555550100', + markReadEnabled: true, + }), +}); + const blooioProvider: 'blooio' = blooioClient.provider; const photonProvider: 'photon' = photonClient.provider; +const sendblueProvider: 'sendblue' = sendblueClient.provider; void blooioProvider; void photonProvider; +void sendblueProvider; void blooioClient.providers.blooio.numbers.list; void photonClient.providers.photon.connection.getLine; +void sendblueClient.providers.sendblue.tapbacks.add; void photonClient.attachments.download; const photonDownloadsAttachments: true = photonClient.capabilities.attachments.download; const blooioDownloadsAttachments: false = blooioClient.capabilities.attachments.download; +const sendblueDownloadsAttachments: false = sendblueClient.capabilities.attachments.download; +const sendblueSendsAttachments: true = sendblueClient.capabilities.messages.attachments; +const sendblueDefaultMarkRead: false = sendblueClient.capabilities.conversations.markRead; +const sendblueDefaultReadReceipts: false = sendblueClient.capabilities.interactions.readReceipts; +const sendblueReadReceipts: false = sendblueMarkReadClient.capabilities.interactions.readReceipts; +const sendblueMarkRead: true = sendblueMarkReadClient.capabilities.conversations.markRead; void photonDownloadsAttachments; void blooioDownloadsAttachments; +void sendblueDownloadsAttachments; +void sendblueSendsAttachments; +void sendblueDefaultMarkRead; +void sendblueDefaultReadReceipts; +void sendblueReadReceipts; +void sendblueMarkRead; const imessage = createIMessageAdapter({ connectionId: 'chat-consumer', provider: blooio({ apiKey: 'test' }), }); +const sendblueImessage = createIMessageAdapter({ + connectionId: 'sendblue-chat-consumer', + provider: sendblue({ apiKey: 'test', apiSecret: 'test', fromNumber: '+15555550100' }), +}); const chatAdapter: Adapter = imessage; const chatProvider: 'blooio' = imessage.client.provider; +const sendblueChatAdapter: Adapter = sendblueImessage; +const sendblueChatProvider: 'sendblue' = sendblueImessage.client.provider; void chatAdapter; void chatProvider; +void sendblueChatAdapter; +void sendblueChatProvider; void imessage.client.providers.blooio.numbers.list; +void sendblueImessage.client.providers.sendblue.tapbacks.add;