mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
feat: add twilio as a new provider
This commit is contained in:
@@ -320,13 +320,14 @@ First-party packages:
|
||||
|
||||
- [`@notifkit/provider-resend`](./packages/provider-resend) — transactional email via Resend
|
||||
- [`@notifkit/provider-fcm`](./packages/provider-fcm) — push notifications via Firebase Cloud Messaging
|
||||
- [`@notifkit/provider-twilio`](./packages/provider-twilio) — SMS via Twilio, with signature-verified delivery status callbacks
|
||||
|
||||
For anything else, implement a simple `Transport`:
|
||||
|
||||
```ts
|
||||
class MyTransport implements Transport {
|
||||
async send(message) {
|
||||
// Send through Twilio, SES, Postmark, APNs,
|
||||
// Send through SES, Postmark, APNs,
|
||||
// SendGrid, a custom webhook, or anything else.
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+167
-7
@@ -8,6 +8,7 @@
|
||||
"": {
|
||||
"name": "notifkit",
|
||||
"version": "0.1.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -1766,6 +1767,10 @@
|
||||
"resolved": "packages/provider-resend",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@notifkit/provider-twilio": {
|
||||
"resolved": "packages/provider-twilio",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@one-ini/wasm": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
@@ -3860,7 +3865,6 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
@@ -3872,6 +3876,59 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
|
||||
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
|
||||
@@ -4473,7 +4530,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@@ -4835,6 +4891,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -4889,7 +4951,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -5963,7 +6024,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -6819,6 +6879,26 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -7483,7 +7563,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -8763,7 +8842,6 @@
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -8773,7 +8851,6 @@
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
@@ -9665,6 +9742,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
@@ -10172,6 +10258,13 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scmp": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz",
|
||||
"integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==",
|
||||
"deprecated": "Just use Node.js's crypto.timingSafeEqual()",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||
@@ -11726,6 +11819,49 @@
|
||||
"dev": true,
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/twilio": {
|
||||
"version": "5.13.1",
|
||||
"resolved": "https://registry.npmjs.org/twilio/-/twilio-5.13.1.tgz",
|
||||
"integrity": "sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.5",
|
||||
"dayjs": "^1.11.9",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"qs": "^6.14.1",
|
||||
"scmp": "^2.1.0",
|
||||
"xmlbuilder": "^13.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/twilio/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/twilio/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -12730,6 +12866,15 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
|
||||
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
@@ -13022,6 +13167,21 @@
|
||||
"peerDependencies": {
|
||||
"notifkit": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"packages/provider-twilio": {
|
||||
"name": "@notifkit/provider-twilio",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"twilio": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"notifkit": "file:../../",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"notifkit": "^0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 devkitshq
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,65 @@
|
||||
# @notifkit/provider-twilio
|
||||
|
||||
Twilio SMS transport for [notifkit](https://github.com/devkitshq/notifkit).
|
||||
Sends through the Twilio Messages API and verifies delivery status callbacks
|
||||
with `X-Twilio-Signature`, so undeliverable numbers and STOP replies feed back
|
||||
into notifkit's suppression list automatically.
|
||||
|
||||
```bash
|
||||
npm install @notifkit/provider-twilio
|
||||
```
|
||||
|
||||
```ts
|
||||
import { TwilioTransport } from "@notifkit/provider-twilio";
|
||||
|
||||
registry.register(
|
||||
"sms",
|
||||
new TwilioTransport({
|
||||
accountSid: process.env.TWILIO_ACCOUNT_SID!,
|
||||
authToken: process.env.TWILIO_AUTH_TOKEN!,
|
||||
from: "+15550000001",
|
||||
statusCallbackUrl: "https://api.example.com/webhooks/twilio",
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
A recipient's `sms` contact target is the destination, in E.164
|
||||
(`+15550000002`). Templates render into `text` (or `body`); a template may also
|
||||
carry its own `from`, the same way other transports let a template override
|
||||
their default sender — useful when a support long code and a marketing short
|
||||
code share one transport.
|
||||
|
||||
## Delivery status callbacks
|
||||
|
||||
`statusCallbackUrl` must be the publicly reachable URL of this transport's
|
||||
webhook route. Twilio signs the URL it was given along with the request body,
|
||||
so signatures can only be checked against that exact string — including any
|
||||
query string. The route is mounted at its path (`/webhooks/twilio` above), and
|
||||
is only mounted at all when the URL is configured: an unverifiable callback is
|
||||
an anonymous, forgeable write into delivery history, so the transport fails
|
||||
closed rather than accepting one.
|
||||
|
||||
Twilio accepts a message before any carrier has seen it, so a successful
|
||||
`send` means `queued`, not delivered. What actually happened arrives on the
|
||||
callback:
|
||||
|
||||
| Twilio status | notifkit event |
|
||||
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `undelivered` / `failed` | `bounced` — `hard` for a permanently bad number (`21211`, `21612`, `21614`, `30005`, `30006`), `soft` otherwise |
|
||||
| `undelivered` / `failed` + `21610` | `unsubscribed` — the recipient replied STOP |
|
||||
| `read` | `opened` (RCS and WhatsApp only; SMS has no read receipt) |
|
||||
| `queued`, `sending`, `sent`, `delivered`, `accepted`, `canceled` | ignored |
|
||||
|
||||
An unrecognised error code is treated as a **soft** bounce. Wrongly
|
||||
suppressing a working number silently stops messages someone still wants, so
|
||||
only codes known to mean the number itself is gone are hard.
|
||||
|
||||
## Opt-outs
|
||||
|
||||
Twilio handles STOP/START itself and rejects a send to an opted-out recipient
|
||||
with error `21610`. That is returned as an invalid token — as are `21211`,
|
||||
`21612`, and `21614` — so notifkit stops retrying and flags the contact.
|
||||
Everything else, including rate limits, stays retryable.
|
||||
|
||||
Requires `notifkit` as a peer dependency. Documentation:
|
||||
[notifkit.dev/docs](https://notifkit.dev/docs/). MIT licensed.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@notifkit/provider-twilio",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"twilio": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"notifkit": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"notifkit": "file:../../",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"description": "Twilio SMS transport for notifkit, with signature-verified delivery status callbacks.",
|
||||
"license": "MIT",
|
||||
"author": "devkitshq",
|
||||
"homepage": "https://notifkit.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/devkitshq/notifkit.git",
|
||||
"directory": "packages/provider-twilio"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/devkitshq/notifkit/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { DeliveryResult, Transport } from "notifkit";
|
||||
import type { NotificationDispatchedPayload, Logger } from "notifkit";
|
||||
import type { WebhookEvent } from "notifkit";
|
||||
import type { Twilio } from "twilio";
|
||||
|
||||
/**
|
||||
* Codes `messages.create` rejects with for a destination that is permanently
|
||||
* bad, as opposed to one that is merely temporarily rejected (rate limits,
|
||||
* account issues, Twilio outages). Only these suppress the contact — anything
|
||||
* else is worth retrying.
|
||||
* https://www.twilio.com/docs/api/errors
|
||||
*/
|
||||
const INVALID_DESTINATION_CODES = new Set([
|
||||
21211, // Invalid 'To' phone number
|
||||
21610, // Attempt to send to unsubscribed recipient (replied STOP)
|
||||
21612, // Message cannot be sent with this To/From combination
|
||||
21614, // 'To' number is not a valid mobile number
|
||||
]);
|
||||
|
||||
/**
|
||||
* Delivery-time codes that arrive on an `undelivered`/`failed` status callback
|
||||
* and mean the number itself is gone, not that this one attempt lost.
|
||||
* Everything unlisted is treated as a soft bounce — an unrecognised code must
|
||||
* never suppress a number a person still uses.
|
||||
*/
|
||||
const HARD_BOUNCE_CODES = new Set([
|
||||
21211, // Invalid 'To' phone number
|
||||
21612, // Not reachable via SMS
|
||||
21614, // Not a valid mobile number
|
||||
30005, // Unknown destination handset — the number does not exist
|
||||
30006, // Landline or unreachable carrier
|
||||
]);
|
||||
|
||||
/** The recipient replied STOP. An opt-out, not a delivery failure. */
|
||||
const OPT_OUT_CODE = 21610;
|
||||
|
||||
export interface TwilioTransportOptions {
|
||||
accountSid: string;
|
||||
authToken: string;
|
||||
/**
|
||||
* Sender used for any template that does not name its own, in E.164
|
||||
* (`+15551234567`). A template may override it with a `from` in its
|
||||
* content — see `send`.
|
||||
*/
|
||||
from: string;
|
||||
logger?: Logger;
|
||||
/**
|
||||
* Publicly reachable URL of this transport's status-callback route, e.g.
|
||||
* `https://api.example.com/webhooks/twilio`. Twilio signs the URL it was
|
||||
* given along with the request body, so signatures can only be checked
|
||||
* against the exact string configured here — including any query string.
|
||||
*
|
||||
* Leave it unset to send without delivery tracking; the webhook route is
|
||||
* only mounted when it is present, because an unverifiable callback is an
|
||||
* anonymous, forgeable write into delivery history.
|
||||
*/
|
||||
statusCallbackUrl?: string;
|
||||
limits?: { limit: number; windowSeconds: number };
|
||||
}
|
||||
|
||||
export class TwilioTransport implements Transport {
|
||||
readonly channel = "sms" as const;
|
||||
readonly webhookPath?: string;
|
||||
readonly limits?: { limit: number; windowSeconds: number };
|
||||
|
||||
private readonly accountSid: string;
|
||||
private readonly authToken: string;
|
||||
private readonly from: string;
|
||||
private readonly logger?: Logger;
|
||||
private readonly statusCallbackUrl?: string;
|
||||
private client: Twilio | null = null;
|
||||
|
||||
constructor({
|
||||
accountSid,
|
||||
authToken,
|
||||
from,
|
||||
logger,
|
||||
statusCallbackUrl,
|
||||
// Twilio queues per sender at roughly one message per second on a long
|
||||
// code, so the ceiling that matters is the sending number's, not the
|
||||
// account's. This is a conservative default for a single long code —
|
||||
// callers on a short code or a Messaging Service should raise it.
|
||||
limits = { limit: 60, windowSeconds: 60 },
|
||||
}: TwilioTransportOptions) {
|
||||
this.accountSid = accountSid;
|
||||
this.authToken = authToken;
|
||||
this.from = from;
|
||||
this.logger = logger;
|
||||
this.statusCallbackUrl = statusCallbackUrl;
|
||||
this.limits = limits;
|
||||
|
||||
// Derive the mounted path from the configured callback URL rather than
|
||||
// taking both separately: the two drifting apart would mount a route
|
||||
// Twilio never calls, or verify against a URL it never signed.
|
||||
if (statusCallbackUrl) {
|
||||
try {
|
||||
this.webhookPath = new URL(statusCallbackUrl).pathname;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`TwilioTransport: statusCallbackUrl is not a valid URL: ${statusCallbackUrl}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK is a large module, so it is loaded on first send rather than at
|
||||
* import time — a process that registers this transport but never sends SMS
|
||||
* should not pay for it.
|
||||
*/
|
||||
private async getClient(): Promise<Twilio> {
|
||||
if (this.client) return this.client;
|
||||
const { default: twilio } = await import("twilio");
|
||||
this.client = twilio(this.accountSid, this.authToken);
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {
|
||||
// `destination` is optional on the contract, so a malformed task can reach
|
||||
// a transport without one. Failing here costs one clear error; passing
|
||||
// `undefined` to Twilio costs an opaque provider rejection and a retry cycle.
|
||||
const to = task.destination;
|
||||
if (!to) {
|
||||
return { success: false, error: "No destination (recipient phone number) on task" };
|
||||
}
|
||||
|
||||
const content = task.renderedContent.content as any;
|
||||
const body = content.text || content.body;
|
||||
if (!body) {
|
||||
return { success: false, error: "No message body on task" };
|
||||
}
|
||||
|
||||
// A template may carry its own sender, so one transport can serve a
|
||||
// support long code and a marketing short code. Non-strings are ignored
|
||||
// rather than coerced: `from: 123` reaching Twilio as "123" would fail the
|
||||
// send with a confusing error.
|
||||
const from = typeof content.from === "string" && content.from ? content.from : this.from;
|
||||
|
||||
try {
|
||||
const client = await this.getClient();
|
||||
const message = await client.messages.create({
|
||||
to,
|
||||
from,
|
||||
body,
|
||||
...(this.statusCallbackUrl ? { statusCallback: this.statusCallbackUrl } : {}),
|
||||
});
|
||||
|
||||
// Twilio accepts the message before a carrier has seen it, so `queued`
|
||||
// here is a successful handoff, not a delivery. Whether it actually
|
||||
// landed arrives later on the status callback.
|
||||
this.logger?.debug(
|
||||
{ taskId: task.taskId, messageId: message.sid, status: message.status },
|
||||
"Twilio message accepted",
|
||||
);
|
||||
return { success: true, providerMessageId: message.sid };
|
||||
} catch (err) {
|
||||
const error = err as Error & { code?: number };
|
||||
const invalidToken = error.code !== undefined && INVALID_DESTINATION_CODES.has(error.code);
|
||||
|
||||
this.logger?.warn(
|
||||
{ taskId: task.taskId, code: error.code, error: error.message, invalidToken },
|
||||
"Twilio send failed",
|
||||
);
|
||||
return { success: false, invalidToken, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature gate for the route the API mounts at `webhookPath`.
|
||||
*
|
||||
* Required, not optional: the mounted route answers 501 for a transport that
|
||||
* omits it, so without this `parseWebhook` is never reached at all.
|
||||
*
|
||||
* Fail closed. Twilio posts status callbacks as `application/x-www-form-
|
||||
* urlencoded`, so the signature covers the callback URL plus the sorted form
|
||||
* fields — a missing signature or an unconfigured URL is a reason to drop
|
||||
* the event, never a reason to trust it.
|
||||
*/
|
||||
async verifyWebhook(
|
||||
rawBody: string,
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
): Promise<boolean> {
|
||||
if (!this.statusCallbackUrl) {
|
||||
this.logger?.error(
|
||||
"Twilio webhook rejected: statusCallbackUrl is not configured, cannot verify signature",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const raw = headers["x-twilio-signature"];
|
||||
const signature = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!signature) {
|
||||
this.logger?.warn("Twilio webhook rejected: missing x-twilio-signature header");
|
||||
return false;
|
||||
}
|
||||
|
||||
const { default: twilio } = await import("twilio");
|
||||
const valid = twilio.validateRequest(
|
||||
this.authToken,
|
||||
signature,
|
||||
this.statusCallbackUrl,
|
||||
parseFormBody(rawBody),
|
||||
);
|
||||
if (!valid) {
|
||||
this.logger?.warn("Twilio webhook signature verification failed");
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
async parseWebhook(
|
||||
_body: any,
|
||||
rawBody?: string,
|
||||
headers?: Record<string, string | string[] | undefined>,
|
||||
): Promise<WebhookEvent[]> {
|
||||
// Verify again rather than trusting the caller. This method is public and
|
||||
// may be called directly, and an unverified callback is a forgeable write
|
||||
// into delivery history.
|
||||
if (rawBody === undefined || !headers || !(await this.verifyWebhook(rawBody, headers))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// The mounted route JSON-parses the body before handing it over, which
|
||||
// yields nothing for a form-encoded post — so read the raw body instead.
|
||||
const params = parseFormBody(rawBody);
|
||||
const providerMessageId = params["MessageSid"] ?? params["SmsSid"];
|
||||
const messageStatus = params["MessageStatus"] ?? params["SmsStatus"];
|
||||
|
||||
this.logger?.debug({ providerMessageId, messageStatus }, "Twilio status callback received");
|
||||
|
||||
if (!providerMessageId || !messageStatus) {
|
||||
this.logger?.warn("Twilio status callback malformed or missing MessageSid");
|
||||
return [];
|
||||
}
|
||||
|
||||
const recipient = params["To"];
|
||||
const errorCode = params["ErrorCode"] ? Number(params["ErrorCode"]) : undefined;
|
||||
|
||||
let status: WebhookEvent["status"];
|
||||
let bounceType: WebhookEvent["bounceType"] | undefined;
|
||||
|
||||
switch (messageStatus) {
|
||||
case "failed":
|
||||
case "undelivered":
|
||||
// A STOP reply is a withdrawal of consent, not a broken number, and
|
||||
// the two want different handling: one should never be messaged
|
||||
// again, the other may come back when the handset does.
|
||||
if (errorCode === OPT_OUT_CODE) {
|
||||
status = "unsubscribed";
|
||||
} else {
|
||||
status = "bounced";
|
||||
bounceType =
|
||||
errorCode !== undefined && HARD_BOUNCE_CODES.has(errorCode) ? "hard" : "soft";
|
||||
}
|
||||
break;
|
||||
case "read":
|
||||
// RCS and WhatsApp report a read receipt; SMS never does.
|
||||
status = "opened";
|
||||
break;
|
||||
default:
|
||||
// queued, sending, sent, delivered, accepted, scheduled, canceled —
|
||||
// progress reports the delivery log already knows how to infer.
|
||||
this.logger?.debug(
|
||||
{ messageStatus },
|
||||
"Twilio status callback ignored (untracked message status)",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
this.logger?.info(
|
||||
{ providerMessageId, status, messageStatus, errorCode },
|
||||
"Twilio status callback mapped to notifkit status",
|
||||
);
|
||||
|
||||
const metadata: Record<string, unknown> = { messageStatus };
|
||||
if (errorCode !== undefined) metadata["errorCode"] = errorCode;
|
||||
if (params["ChannelStatusMessage"]) {
|
||||
metadata["channelStatusMessage"] = params["ChannelStatusMessage"];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
providerMessageId,
|
||||
status,
|
||||
timestamp: new Date(),
|
||||
...(recipient ? { recipient } : {}),
|
||||
...(bounceType ? { bounceType } : {}),
|
||||
metadata,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Twilio posts status callbacks form-encoded, and signs every field it sent —
|
||||
* including ones added after this was written — so collect all of them rather
|
||||
* than only the ones read below, or verification fails on Twilio's next
|
||||
* addition.
|
||||
*/
|
||||
function parseFormBody(rawBody: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of new URLSearchParams(rawBody)) {
|
||||
params[key] = value;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -492,3 +492,464 @@ describe("ConsoleTransport (Development Provider)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Twilio ──────────────────────────────────────────────────────────────────
|
||||
|
||||
import { TwilioTransport } from "../packages/provider-twilio/src/index.js";
|
||||
|
||||
const mockCreateMessage = vi.fn();
|
||||
|
||||
// Mock only the client factory. `validateRequest` stays real, so the webhook
|
||||
// tests exercise Twilio's actual HMAC-SHA1 check rather than a stub of it.
|
||||
vi.mock("twilio", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any;
|
||||
const real = actual.default ?? actual;
|
||||
const client = vi.fn(() => ({ messages: { create: mockCreateMessage } }));
|
||||
return { default: Object.assign(client, real) };
|
||||
});
|
||||
|
||||
const ACCOUNT_SID = "ACtest00000000000000000000000000";
|
||||
const AUTH_TOKEN = "test-auth-token";
|
||||
const CALLBACK_URL = "https://api.example.com/webhooks/twilio";
|
||||
|
||||
function twilioTransport(overrides: Record<string, unknown> = {}) {
|
||||
return new TwilioTransport({
|
||||
accountSid: ACCOUNT_SID,
|
||||
authToken: AUTH_TOKEN,
|
||||
from: "+15550000001",
|
||||
statusCallbackUrl: CALLBACK_URL,
|
||||
...overrides,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function smsTask(overrides: Record<string, unknown> = {}): any {
|
||||
return {
|
||||
taskId: "task-uuid-sms-1",
|
||||
destination: "+15550000002",
|
||||
renderedContent: { content: { body: "Your code is 123456" } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TwilioTransport (SMS Provider)", () => {
|
||||
beforeEach(() => {
|
||||
mockCreateMessage.mockReset();
|
||||
});
|
||||
|
||||
it("registers against the sms channel", () => {
|
||||
expect(twilioTransport().channel).toBe("sms");
|
||||
});
|
||||
|
||||
it("sends an SMS via the Twilio SDK and returns the message sid", async () => {
|
||||
mockCreateMessage.mockResolvedValueOnce({ sid: "SM123", status: "queued" });
|
||||
|
||||
const result = await twilioTransport().send(smsTask());
|
||||
|
||||
expect(mockCreateMessage).toHaveBeenCalledWith({
|
||||
to: "+15550000002",
|
||||
from: "+15550000001",
|
||||
body: "Your code is 123456",
|
||||
statusCallback: CALLBACK_URL,
|
||||
});
|
||||
expect(result).toEqual({ success: true, providerMessageId: "SM123" });
|
||||
});
|
||||
|
||||
it("omits statusCallback when no callback URL is configured", async () => {
|
||||
mockCreateMessage.mockResolvedValueOnce({ sid: "SM124", status: "queued" });
|
||||
|
||||
await twilioTransport({ statusCallbackUrl: undefined }).send(smsTask());
|
||||
|
||||
expect(mockCreateMessage).toHaveBeenCalledWith({
|
||||
to: "+15550000002",
|
||||
from: "+15550000001",
|
||||
body: "Your code is 123456",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a template pin its own sender, ignoring a non-string one", async () => {
|
||||
mockCreateMessage.mockResolvedValue({ sid: "SM125", status: "queued" });
|
||||
|
||||
await twilioTransport().send(
|
||||
smsTask({ renderedContent: { content: { body: "hi", from: "+15559999999" } } }),
|
||||
);
|
||||
expect(mockCreateMessage.mock.calls[0]?.[0].from).toBe("+15559999999");
|
||||
|
||||
await twilioTransport().send(
|
||||
smsTask({ renderedContent: { content: { body: "hi", from: 123 } } }),
|
||||
);
|
||||
expect(mockCreateMessage.mock.calls[1]?.[0].from).toBe("+15550000001");
|
||||
});
|
||||
|
||||
it("fails without reaching Twilio when the task carries no destination", async () => {
|
||||
const result = await twilioTransport().send(smsTask({ destination: undefined }));
|
||||
|
||||
expect(mockCreateMessage).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "No destination (recipient phone number) on task",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails without reaching Twilio when the task carries no body", async () => {
|
||||
const result = await twilioTransport().send(
|
||||
smsTask({ renderedContent: { content: { subject: "no body here" } } }),
|
||||
);
|
||||
|
||||
expect(mockCreateMessage).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: false, error: "No message body on task" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[21211, "Invalid 'To' Phone Number"],
|
||||
[21610, "Attempt to send to unsubscribed recipient"],
|
||||
[21612, "Not reachable via SMS"],
|
||||
[21614, "'To' number is not a valid mobile number"],
|
||||
])("flags a dead destination (%i) as an invalid token", async (code, message) => {
|
||||
mockCreateMessage.mockRejectedValueOnce(Object.assign(new Error(message), { code }));
|
||||
|
||||
await expect(twilioTransport().send(smsTask())).resolves.toEqual({
|
||||
success: false,
|
||||
invalidToken: true,
|
||||
error: message,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a retryable failure retryable rather than suppressing the number", async () => {
|
||||
// 20429 is Twilio's rate limit: the number is fine, this attempt was not.
|
||||
mockCreateMessage.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Too Many Requests"), { code: 20429 }),
|
||||
);
|
||||
|
||||
await expect(twilioTransport().send(smsTask())).resolves.toEqual({
|
||||
success: false,
|
||||
invalidToken: false,
|
||||
error: "Too Many Requests",
|
||||
});
|
||||
});
|
||||
|
||||
it("survives an error carrying no Twilio code at all", async () => {
|
||||
mockCreateMessage.mockRejectedValueOnce(new Error("socket hang up"));
|
||||
|
||||
await expect(twilioTransport().send(smsTask())).resolves.toEqual({
|
||||
success: false,
|
||||
invalidToken: false,
|
||||
error: "socket hang up",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwilioTransport (Status callbacks)", () => {
|
||||
/** Produce a body + headers Twilio's own validator accepts. */
|
||||
async function sign(params: Record<string, string>, url = CALLBACK_URL) {
|
||||
const twilio = ((await import("twilio")) as any).default;
|
||||
const rawBody = new URLSearchParams(params).toString();
|
||||
return {
|
||||
rawBody,
|
||||
headers: {
|
||||
"x-twilio-signature": twilio.getExpectedTwilioSignature(AUTH_TOKEN, url, params),
|
||||
} as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
const undelivered = {
|
||||
MessageSid: "SM123",
|
||||
MessageStatus: "undelivered",
|
||||
To: "+15550000002",
|
||||
From: "+15550000001",
|
||||
ErrorCode: "30005",
|
||||
};
|
||||
|
||||
it("mounts its route at the path of the configured callback URL", () => {
|
||||
const transport = twilioTransport();
|
||||
|
||||
// The API only mounts a webhook route for a transport carrying all three.
|
||||
expect(transport.webhookPath).toBe("/webhooks/twilio");
|
||||
expect(typeof transport.verifyWebhook).toBe("function");
|
||||
expect(typeof transport.parseWebhook).toBe("function");
|
||||
});
|
||||
|
||||
it("advertises no route when no callback URL is configured", () => {
|
||||
expect(twilioTransport({ statusCallbackUrl: undefined }).webhookPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to construct with a callback URL that is not a URL", () => {
|
||||
expect(() => twilioTransport({ statusCallbackUrl: "not-a-url" })).toThrow(/not a valid URL/);
|
||||
});
|
||||
|
||||
it("accepts a correctly signed callback", async () => {
|
||||
const { rawBody, headers } = await sign(undelivered);
|
||||
|
||||
await expect(twilioTransport().verifyWebhook(rawBody, headers)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a callback whose body was tampered with after signing", async () => {
|
||||
const { headers } = await sign(undelivered);
|
||||
const forged = new URLSearchParams({
|
||||
...undelivered,
|
||||
MessageSid: "someone-elses-message",
|
||||
}).toString();
|
||||
|
||||
await expect(twilioTransport().verifyWebhook(forged, headers)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a callback signed for a different URL", async () => {
|
||||
const { rawBody, headers } = await sign(
|
||||
undelivered,
|
||||
"https://evil.example.com/webhooks/twilio",
|
||||
);
|
||||
|
||||
await expect(twilioTransport().verifyWebhook(rawBody, headers)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a callback with no signature header", async () => {
|
||||
const { rawBody } = await sign(undelivered);
|
||||
|
||||
await expect(twilioTransport().verifyWebhook(rawBody, {})).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects everything when no callback URL is configured", async () => {
|
||||
const { rawBody, headers } = await sign(undelivered);
|
||||
|
||||
await expect(
|
||||
twilioTransport({ statusCallbackUrl: undefined }).verifyWebhook(rawBody, headers),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("reads the form-encoded body the mounted route could not JSON-parse", async () => {
|
||||
const { rawBody, headers } = await sign(undelivered);
|
||||
|
||||
// The route hands over `{}` for a form post; the raw body is the payload.
|
||||
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
providerMessageId: "SM123",
|
||||
status: "bounced",
|
||||
bounceType: "hard",
|
||||
recipient: "+15550000002",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an unrecognised failure code as a soft bounce", async () => {
|
||||
const { rawBody, headers } = await sign({
|
||||
...undelivered,
|
||||
MessageStatus: "failed",
|
||||
ErrorCode: "30008",
|
||||
});
|
||||
|
||||
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
|
||||
|
||||
expect(events[0]).toMatchObject({ status: "bounced", bounceType: "soft" });
|
||||
});
|
||||
|
||||
it("treats a failure with no code at all as a soft bounce", async () => {
|
||||
const { MessageSid, MessageStatus, To, From } = undelivered;
|
||||
const { rawBody, headers } = await sign({ MessageSid, MessageStatus, To, From });
|
||||
|
||||
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
|
||||
|
||||
expect(events[0]).toMatchObject({ status: "bounced", bounceType: "soft" });
|
||||
});
|
||||
|
||||
it("maps a STOP reply to an unsubscribe, not a bounce", async () => {
|
||||
const { rawBody, headers } = await sign({ ...undelivered, ErrorCode: "21610" });
|
||||
|
||||
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
providerMessageId: "SM123",
|
||||
status: "unsubscribed",
|
||||
recipient: "+15550000002",
|
||||
});
|
||||
expect(events[0]?.bounceType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps an RCS/WhatsApp read receipt to opened", async () => {
|
||||
const { rawBody, headers } = await sign({
|
||||
MessageSid: "SM123",
|
||||
MessageStatus: "read",
|
||||
To: "+15550000002",
|
||||
});
|
||||
|
||||
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
|
||||
|
||||
expect(events[0]).toMatchObject({ status: "opened" });
|
||||
});
|
||||
|
||||
it.each(["queued", "sending", "sent", "delivered", "accepted", "canceled"])(
|
||||
"ignores the untracked %s status",
|
||||
async (MessageStatus) => {
|
||||
const { rawBody, headers } = await sign({ MessageSid: "SM123", MessageStatus });
|
||||
|
||||
await expect(twilioTransport().parseWebhook({}, rawBody, headers)).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("falls back to the SmsSid/SmsStatus aliases Twilio sends for SMS", async () => {
|
||||
const { rawBody, headers } = await sign({
|
||||
SmsSid: "SM999",
|
||||
SmsStatus: "undelivered",
|
||||
To: "+15550000002",
|
||||
ErrorCode: "30006",
|
||||
});
|
||||
|
||||
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
|
||||
|
||||
expect(events[0]).toMatchObject({ providerMessageId: "SM999", bounceType: "hard" });
|
||||
});
|
||||
|
||||
it("ignores a verified callback that carries no message sid", async () => {
|
||||
const { rawBody, headers } = await sign({ MessageStatus: "undelivered" });
|
||||
|
||||
await expect(twilioTransport().parseWebhook({}, rawBody, headers)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("does not trust a body that fails verification, even if it is well formed", async () => {
|
||||
// Valid-looking payload, signature for a different body.
|
||||
const { headers } = await sign({ MessageSid: "other", MessageStatus: "delivered" });
|
||||
const rawBody = new URLSearchParams(undelivered).toString();
|
||||
|
||||
await expect(twilioTransport().parseWebhook({}, rawBody, headers)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a callback that arrives without a raw body to verify", async () => {
|
||||
const { headers } = await sign(undelivered);
|
||||
|
||||
await expect(twilioTransport().parseWebhook({}, undefined, headers)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the provider detail worth correlating on in metadata", async () => {
|
||||
const { rawBody, headers } = await sign({
|
||||
...undelivered,
|
||||
ChannelStatusMessage: "Unknown destination handset",
|
||||
});
|
||||
|
||||
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
|
||||
|
||||
expect(events[0]?.metadata).toEqual({
|
||||
messageStatus: "undelivered",
|
||||
errorCode: 30005,
|
||||
channelStatusMessage: "Unknown destination handset",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwilioTransport (Client and logging)", () => {
|
||||
const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateMessage.mockReset();
|
||||
});
|
||||
|
||||
it("builds the SDK client once and reuses it across sends", async () => {
|
||||
const twilio = ((await import("twilio")) as any).default;
|
||||
twilio.mockClear();
|
||||
mockCreateMessage.mockResolvedValue({ sid: "SM126", status: "queued" });
|
||||
|
||||
const transport = twilioTransport();
|
||||
await transport.send(smsTask());
|
||||
await transport.send(smsTask());
|
||||
|
||||
expect(mockCreateMessage).toHaveBeenCalledTimes(2);
|
||||
expect(twilio).toHaveBeenCalledTimes(1);
|
||||
expect(twilio).toHaveBeenCalledWith(ACCOUNT_SID, AUTH_TOKEN);
|
||||
});
|
||||
|
||||
it("logs an accepted send and a failed one", async () => {
|
||||
const log = logger();
|
||||
mockCreateMessage.mockResolvedValueOnce({ sid: "SM127", status: "queued" });
|
||||
await twilioTransport({ logger: log }).send(smsTask());
|
||||
expect(log.debug).toHaveBeenCalledWith(
|
||||
{ taskId: "task-uuid-sms-1", messageId: "SM127", status: "queued" },
|
||||
"Twilio message accepted",
|
||||
);
|
||||
|
||||
mockCreateMessage.mockRejectedValueOnce(Object.assign(new Error("nope"), { code: 21211 }));
|
||||
await twilioTransport({ logger: log }).send(smsTask());
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
{ taskId: "task-uuid-sms-1", code: 21211, error: "nope", invalidToken: true },
|
||||
"Twilio send failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a signature header that arrived as an array", async () => {
|
||||
const rawBody = new URLSearchParams({ MessageSid: "SM128", MessageStatus: "read" }).toString();
|
||||
const twilio = ((await import("twilio")) as any).default;
|
||||
const signature = twilio.getExpectedTwilioSignature(AUTH_TOKEN, CALLBACK_URL, {
|
||||
MessageSid: "SM128",
|
||||
MessageStatus: "read",
|
||||
});
|
||||
|
||||
await expect(
|
||||
twilioTransport().verifyWebhook(rawBody, { "x-twilio-signature": [signature] }),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("logs each reason a callback is dropped", async () => {
|
||||
const log = logger();
|
||||
const rawBody = new URLSearchParams({ MessageSid: "SM129", MessageStatus: "sent" }).toString();
|
||||
|
||||
await twilioTransport({ logger: log, statusCallbackUrl: undefined }).verifyWebhook(rawBody, {});
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
"Twilio webhook rejected: statusCallbackUrl is not configured, cannot verify signature",
|
||||
);
|
||||
|
||||
await twilioTransport({ logger: log }).verifyWebhook(rawBody, {});
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
"Twilio webhook rejected: missing x-twilio-signature header",
|
||||
);
|
||||
|
||||
await twilioTransport({ logger: log }).verifyWebhook(rawBody, {
|
||||
"x-twilio-signature": "not-the-signature",
|
||||
});
|
||||
expect(log.warn).toHaveBeenCalledWith("Twilio webhook signature verification failed");
|
||||
});
|
||||
|
||||
it("logs a mapped callback, an ignored one, and a malformed one", async () => {
|
||||
const log = logger();
|
||||
const transport = twilioTransport({ logger: log });
|
||||
|
||||
async function deliver(params: Record<string, string>) {
|
||||
const twilio = ((await import("twilio")) as any).default;
|
||||
const rawBody = new URLSearchParams(params).toString();
|
||||
return transport.parseWebhook({}, rawBody, {
|
||||
"x-twilio-signature": twilio.getExpectedTwilioSignature(AUTH_TOKEN, CALLBACK_URL, params),
|
||||
});
|
||||
}
|
||||
|
||||
await deliver({ MessageSid: "SM130", MessageStatus: "undelivered", ErrorCode: "30005" });
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
{
|
||||
providerMessageId: "SM130",
|
||||
status: "bounced",
|
||||
messageStatus: "undelivered",
|
||||
errorCode: 30005,
|
||||
},
|
||||
"Twilio status callback mapped to notifkit status",
|
||||
);
|
||||
|
||||
await deliver({ MessageSid: "SM131", MessageStatus: "delivered" });
|
||||
expect(log.debug).toHaveBeenCalledWith(
|
||||
{ messageStatus: "delivered" },
|
||||
"Twilio status callback ignored (untracked message status)",
|
||||
);
|
||||
|
||||
await deliver({ MessageStatus: "undelivered" });
|
||||
expect(log.warn).toHaveBeenCalledWith("Twilio status callback malformed or missing MessageSid");
|
||||
});
|
||||
|
||||
it("omits recipient when the callback carries no To", async () => {
|
||||
const params = { MessageSid: "SM132", MessageStatus: "undelivered", ErrorCode: "30005" };
|
||||
const twilio = ((await import("twilio")) as any).default;
|
||||
const events = await twilioTransport().parseWebhook(
|
||||
{},
|
||||
new URLSearchParams(params).toString(),
|
||||
{
|
||||
"x-twilio-signature": twilio.getExpectedTwilioSignature(AUTH_TOKEN, CALLBACK_URL, params),
|
||||
},
|
||||
);
|
||||
|
||||
expect(events[0]).toMatchObject({ providerMessageId: "SM132", status: "bounced" });
|
||||
expect(events[0]).not.toHaveProperty("recipient");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user