@betternotify/selligent 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Better-Notify contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # @betternotify/selligent
2
+
3
+ [Selligent (Marigold Engage)](https://www.marigold.com/products/marigold-engage) transactional email transport for [Better-Notify](https://github.com/better-notify/better-notify). Delivers rendered emails through the Selligent Delivery Cloud (SDC) `POST /email/v1/messages/send` API.
4
+
5
+ <p>
6
+ <a href="https://better-notify.com">Website</a> ·
7
+ <a href="https://better-notify.com/docs">Docs</a> ·
8
+ <a href="https://github.com/better-notify/better-notify">GitHub</a> ·
9
+ <a href="https://x.com/better_notify">X</a>
10
+ </p>
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install @betternotify/selligent @betternotify/core @betternotify/email
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```ts
21
+ import { createNotify, createClient } from '@betternotify/core';
22
+ import { emailChannel } from '@betternotify/email';
23
+ import { selligentTransport } from '@betternotify/selligent';
24
+
25
+ const email = emailChannel({
26
+ defaults: { from: { name: 'My App', email: 'noreply@example.com' } },
27
+ });
28
+
29
+ const rpc = createNotify({ channels: { email } });
30
+ const catalog = rpc.catalog({
31
+ /* routes */
32
+ });
33
+
34
+ const mail = createClient({
35
+ catalog,
36
+ channels: { email },
37
+ transportsByChannel: {
38
+ email: selligentTransport({
39
+ clientId: Number(process.env.SELLIGENT_CLIENT_ID!),
40
+ clientSecret: process.env.SELLIGENT_CLIENT_SECRET!,
41
+ accountId: process.env.SELLIGENT_ACCOUNT_ID!,
42
+ }),
43
+ },
44
+ });
45
+ ```
46
+
47
+ Alternatively, if you already manage OAuth tokens externally:
48
+
49
+ ```ts
50
+ selligentTransport({
51
+ getAccessToken: () => myTokenManager.getToken(),
52
+ });
53
+ ```
54
+
55
+ ## Options
56
+
57
+ | Field | Type | Description |
58
+ | -------------- | ---------- | ------------------------------------------------------------------------------------ |
59
+ | `clientId` | `number` | Selligent OAuth client ID. Required (unless using `getAccessToken`). |
60
+ | `clientSecret` | `string` | Selligent OAuth client secret. Required (unless using `getAccessToken`). |
61
+ | `accountId` | `string` | Selligent account ID. Required (unless using `getAccessToken`). |
62
+ | `getAccessToken` | `() => Promise<string>` | Provide your own token. Mutually exclusive with OAuth credentials. |
63
+ | `baseUrl` | `string` | Override the SDC API base URL. Defaults to `https://sdc.slgnt.eu`. |
64
+ | `authUrl` | `string` | Override the OAuth token endpoint. Defaults to `https://auth.slgnt.eu/oauth/token`. |
65
+ | `audience` | `string` | Override the OAuth audience. Defaults to `https://sdc.slgnt.eu`. |
66
+ | `logger` | `object` | Optional `LoggerLike`. Defaults to `consoleLogger()`. |
67
+ | `http` | `object` | HTTP behavior options (retry, timeout, hooks). |
68
+
69
+ ## Authentication
70
+
71
+ Unlike API-key-based transports, Selligent uses **OAuth 2.0 client credentials**. The transport handles token management automatically — it fetches a token before the first send, caches it, and refreshes it when it's about to expire (within 60 seconds of expiry).
72
+
73
+ The `verify()` method tests credentials by attempting a token fetch without sending any email.
74
+
75
+ ## Per-send overrides
76
+
77
+ Pass Selligent-specific fields per-send via the `transport` key in `.send()`:
78
+
79
+ ```ts
80
+ await mail.welcome.send({
81
+ to: 'user@example.com',
82
+ input: { name: 'Jane' },
83
+ transport: {
84
+ selligent: {
85
+ profile: 'crm-id-123',
86
+ tags: ['campaign-spring'],
87
+ metadata: '{"ref": 123}',
88
+ list_unsubscribe: '<https://example.com/unsub>',
89
+ custom_send_time: '2025-09-02T12:00:00',
90
+ time_to_live: 'P2D',
91
+ },
92
+ },
93
+ });
94
+ ```
95
+
96
+ ## Caveats
97
+
98
+ SDC sends message content without any modifications — it does not inject tracking pixels or rewrite links. Open/click tracking must be implemented by the sender.
99
+
100
+ The following `RenderedMessage` fields are not part of SDC's send schema and are silently dropped: `cc`, `bcc`, custom `headers`, `tags`, `priority`, and `inlineAssets`.
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,84 @@
1
+ import { LoggerLike } from "@betternotify/core";
2
+ import { HttpClientBehaviorOptions } from "@betternotify/core/transports";
3
+ import * as _betternotify_email0 from "@betternotify/email";
4
+
5
+ //#region src/types.d.ts
6
+ type SelligentOAuthCredentials = {
7
+ clientId: number;
8
+ clientSecret: string;
9
+ accountId: string;
10
+ };
11
+ type SelligentTransportOptions = {
12
+ baseUrl?: string;
13
+ authUrl?: string;
14
+ audience?: string;
15
+ logger?: LoggerLike;
16
+ http?: HttpClientBehaviorOptions;
17
+ } & (SelligentOAuthCredentials | {
18
+ getAccessToken: () => Promise<string>;
19
+ });
20
+ type SelligentAddress = {
21
+ alias: string;
22
+ address: string;
23
+ };
24
+ type SelligentAttachment = {
25
+ file_name: string;
26
+ data: string;
27
+ mime_type: string;
28
+ };
29
+ type SelligentMessageContext = {
30
+ profile: string;
31
+ tags?: string[];
32
+ metadata?: string;
33
+ };
34
+ type SelligentMessageItem = {
35
+ reference: string;
36
+ content: {
37
+ html: string;
38
+ text?: string;
39
+ attachments?: SelligentAttachment[];
40
+ };
41
+ headers: {
42
+ subject: string;
43
+ to: SelligentAddress;
44
+ from: SelligentAddress;
45
+ reply?: SelligentAddress;
46
+ list_unsubscribe?: string;
47
+ };
48
+ context: SelligentMessageContext;
49
+ custom_send_time?: string;
50
+ time_to_live?: string;
51
+ };
52
+ type SelligentTokenResponse = {
53
+ access_token: string;
54
+ token_type: string;
55
+ refresh_token: string;
56
+ scope: string;
57
+ expires_in: number;
58
+ };
59
+ type SelligentErrorResponse = {
60
+ error_message?: string;
61
+ };
62
+ type SelligentPerSendData = {
63
+ profile?: string;
64
+ tags?: string[];
65
+ metadata?: string;
66
+ reference?: string;
67
+ list_unsubscribe?: string;
68
+ custom_send_time?: string;
69
+ time_to_live?: string;
70
+ };
71
+ //#endregion
72
+ //#region src/is-retriable.d.ts
73
+ declare const isSelligentRetriable: (err: unknown) => boolean;
74
+ //#endregion
75
+ //#region src/index.d.ts
76
+ declare module '@betternotify/core' {
77
+ interface TransportDataMap {
78
+ selligent: SelligentPerSendData;
79
+ }
80
+ }
81
+ /** @experimental Selligent (Marigold Engage) transport using the SDC transactional email API. */
82
+ declare const selligentTransport: (opts: SelligentTransportOptions) => _betternotify_email0.Transport;
83
+ //#endregion
84
+ export { type SelligentAddress, type SelligentAttachment, type SelligentErrorResponse, type SelligentMessageContext, type SelligentMessageItem, type SelligentOAuthCredentials, type SelligentPerSendData, type SelligentTokenResponse, type SelligentTransportOptions, isSelligentRetriable, selligentTransport };
package/dist/index.js ADDED
@@ -0,0 +1,205 @@
1
+ import { createTransport, normalizeAddress } from "@betternotify/email/transports";
2
+ import { NotifyRpcError, NotifyRpcProviderError, consoleLogger, handlePromise } from "@betternotify/core";
3
+ import { createHttpClient, mapHttpStatus } from "@betternotify/core/transports";
4
+ //#region src/is-retriable.ts
5
+ const isSelligentRetriable = (err) => {
6
+ if (err instanceof NotifyRpcProviderError) return err.retriable;
7
+ return true;
8
+ };
9
+ //#endregion
10
+ //#region src/index.ts
11
+ const DEFAULT_BASE_URL = "https://sdc.slgnt.eu";
12
+ const DEFAULT_AUTH_URL = "https://auth.slgnt.eu/oauth/token";
13
+ const DEFAULT_AUDIENCE = "https://sdc.slgnt.eu";
14
+ const DEFAULT_TIMEOUT_MS = 3e4;
15
+ const TOKEN_REFRESH_BUFFER_MS = 6e4;
16
+ const toBase64 = (content) => {
17
+ if (Buffer.isBuffer(content)) return content.toString("base64");
18
+ return Buffer.from(content).toString("base64");
19
+ };
20
+ const toSelligentAddress = (addr) => {
21
+ if (typeof addr === "string") return {
22
+ alias: "",
23
+ address: addr
24
+ };
25
+ return {
26
+ alias: addr.name ?? "",
27
+ address: addr.email
28
+ };
29
+ };
30
+ const toAttachments = (attachments) => attachments.map((att) => ({
31
+ file_name: att.filename,
32
+ data: toBase64(att.content),
33
+ mime_type: att.contentType ?? "application/octet-stream"
34
+ }));
35
+ const hasOAuthCredentials = (opts) => "clientId" in opts;
36
+ const createTokenManager = (opts) => {
37
+ if (!hasOAuthCredentials(opts)) return { getToken: opts.getAccessToken };
38
+ const authUrl = opts.authUrl ?? DEFAULT_AUTH_URL;
39
+ const audience = opts.audience ?? DEFAULT_AUDIENCE;
40
+ let cachedToken;
41
+ let expiresAt = 0;
42
+ const getToken = async () => {
43
+ if (cachedToken && Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) return cachedToken;
44
+ const res = await fetch(authUrl, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({
48
+ client_id: opts.clientId,
49
+ client_secret: opts.clientSecret,
50
+ account_id: opts.accountId,
51
+ grant_type: "client_credentials",
52
+ audience
53
+ })
54
+ });
55
+ if (!res.ok) {
56
+ const [, body] = await handlePromise(res.text());
57
+ throw new NotifyRpcProviderError({
58
+ message: `Selligent transport: OAuth token request failed [${res.status}]${body ? `: ${body}` : ""}`,
59
+ code: "CONFIG",
60
+ provider: "selligent",
61
+ httpStatus: res.status,
62
+ retriable: res.status >= 500
63
+ });
64
+ }
65
+ const data = await res.json();
66
+ cachedToken = data.access_token;
67
+ expiresAt = Date.now() + data.expires_in * 1e3;
68
+ return cachedToken;
69
+ };
70
+ return { getToken };
71
+ };
72
+ const buildMessageItems = (message, from, ctx, perSend) => message.to.map((to, i) => {
73
+ const item = {
74
+ reference: perSend?.reference ?? `${ctx.messageId}-${i}`,
75
+ content: { html: message.html },
76
+ headers: {
77
+ subject: message.subject,
78
+ to: toSelligentAddress(to),
79
+ from: toSelligentAddress(from)
80
+ },
81
+ context: { profile: perSend?.profile ?? normalizeAddress(to) }
82
+ };
83
+ if (message.text) item.content.text = message.text;
84
+ if (message.attachments?.length) item.content.attachments = toAttachments(message.attachments);
85
+ if (message.replyTo) item.headers.reply = toSelligentAddress(message.replyTo);
86
+ if (perSend?.list_unsubscribe) item.headers.list_unsubscribe = perSend.list_unsubscribe;
87
+ if (perSend?.tags) item.context.tags = perSend.tags;
88
+ if (perSend?.metadata) item.context.metadata = perSend.metadata;
89
+ if (perSend?.custom_send_time) item.custom_send_time = perSend.custom_send_time;
90
+ if (perSend?.time_to_live) item.time_to_live = perSend.time_to_live;
91
+ return item;
92
+ });
93
+ /** @experimental Selligent (Marigold Engage) transport using the SDC transactional email API. */
94
+ const selligentTransport = (opts) => {
95
+ const url = `${(opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "")}/email/v1/messages/send`;
96
+ const log = (opts.logger ?? consoleLogger()).child({ component: "selligent" });
97
+ const http = createHttpClient({
98
+ ...opts.http,
99
+ timeoutMs: opts.http?.timeoutMs ?? DEFAULT_TIMEOUT_MS
100
+ });
101
+ const tokenManager = createTokenManager(opts);
102
+ return createTransport({
103
+ name: "selligent",
104
+ async verify() {
105
+ const [err, token] = await handlePromise(tokenManager.getToken());
106
+ if (err) return {
107
+ ok: false,
108
+ details: err.message
109
+ };
110
+ return {
111
+ ok: true,
112
+ details: { tokenLength: token.length }
113
+ };
114
+ },
115
+ async send(message, ctx) {
116
+ if (!message.from) throw new NotifyRpcError({
117
+ message: "Selligent transport: no \"from\" address. Set it on the channel default, route `.from()` resolver, or per-call args.",
118
+ code: "CONFIG",
119
+ route: ctx.route,
120
+ messageId: ctx.messageId
121
+ });
122
+ const [tokenErr, token] = await handlePromise(tokenManager.getToken());
123
+ if (tokenErr) {
124
+ if (tokenErr instanceof NotifyRpcProviderError) return {
125
+ ok: false,
126
+ error: tokenErr
127
+ };
128
+ return {
129
+ ok: false,
130
+ error: new NotifyRpcProviderError({
131
+ message: `Selligent transport: failed to obtain access token: ${tokenErr.message}`,
132
+ code: "CONFIG",
133
+ provider: "selligent",
134
+ retriable: true,
135
+ route: ctx.route,
136
+ messageId: ctx.messageId,
137
+ cause: tokenErr
138
+ })
139
+ };
140
+ }
141
+ const perSend = ctx.transport?.selligent;
142
+ const items = buildMessageItems(message, message.from, ctx, perSend);
143
+ const result = await http.request(url, {
144
+ method: "POST",
145
+ headers: {
146
+ Authorization: `Bearer ${token}`,
147
+ "Content-Type": "application/json"
148
+ },
149
+ body: JSON.stringify(items)
150
+ });
151
+ if (!result.ok) {
152
+ if (result.kind === "network") {
153
+ log.error("Selligent fetch failed", {
154
+ err: result.cause,
155
+ route: ctx.route
156
+ });
157
+ return {
158
+ ok: false,
159
+ error: new NotifyRpcProviderError({
160
+ message: `Selligent transport: ${result.timedOut ? "request timed out" : `network error: ${result.cause.message}`}`,
161
+ code: result.timedOut ? "TIMEOUT" : "PROVIDER",
162
+ provider: "selligent",
163
+ retriable: true,
164
+ route: ctx.route,
165
+ messageId: ctx.messageId,
166
+ cause: result.cause
167
+ })
168
+ };
169
+ }
170
+ const errData = result.body ?? {};
171
+ const { code, retriable } = mapHttpStatus(result.status);
172
+ const errorMessage = `Selligent transport: [${result.status}] ${errData.error_message ?? "request failed"}`;
173
+ log.error(errorMessage, {
174
+ err: {
175
+ status: result.status,
176
+ body: errData
177
+ },
178
+ route: ctx.route
179
+ });
180
+ return {
181
+ ok: false,
182
+ error: new NotifyRpcProviderError({
183
+ message: errorMessage,
184
+ code,
185
+ provider: "selligent",
186
+ httpStatus: result.status,
187
+ retriable,
188
+ route: ctx.route,
189
+ messageId: ctx.messageId
190
+ })
191
+ };
192
+ }
193
+ return {
194
+ ok: true,
195
+ data: {
196
+ accepted: message.to.map(normalizeAddress),
197
+ rejected: [],
198
+ raw: items
199
+ }
200
+ };
201
+ }
202
+ });
203
+ };
204
+ //#endregion
205
+ export { isSelligentRetriable, selligentTransport };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@betternotify/selligent",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Selligent (Marigold Engage) transactional email transport for Better-Notify.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/better-notify/better-notify",
9
+ "directory": "packages/selligent"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "dependencies": {
25
+ "@betternotify/core": "1.0.0-beta.4",
26
+ "@betternotify/email": "1.0.0-beta.2"
27
+ },
28
+ "devDependencies": {
29
+ "rolldown": "1.0.0",
30
+ "typescript": "6.0.3",
31
+ "vitest": "2.1.9",
32
+ "@internal/rolldown-config": "0.0.0",
33
+ "@internal/tsconfig": "0.0.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "scripts": {
39
+ "build": "NODE_OPTIONS='--import tsx/esm' rolldown -c",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "vitest run"
42
+ }
43
+ }