@bloomscorp/bloomsight-node 0.0.1-placeholder.0 → 0.1.0
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.md +21 -0
- package/README.md +135 -2
- package/dist/index.d.mts +186 -0
- package/dist/index.d.ts +186 -0
- package/dist/index.js +241 -0
- package/dist/index.mjs +212 -0
- package/package.json +53 -4
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bloomscorp
|
|
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
CHANGED
|
@@ -1,4 +1,137 @@
|
|
|
1
1
|
# @bloomscorp/bloomsight-node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Send [Bloomsight](https://bloomscorp.com) email templates from your Node.js backend.
|
|
4
|
+
|
|
5
|
+
Bloomsight decides what every email says: you send a **template id and values**, never
|
|
6
|
+
a subject or HTML. The template, its recipients and the mailbox it goes from are set up
|
|
7
|
+
in your project's Bloomsight dashboard.
|
|
8
|
+
|
|
9
|
+
- **Safe to retry.** Every send carries an idempotency key, so a retry, a timeout or a
|
|
10
|
+
restarted process never sends the same email twice.
|
|
11
|
+
- **Retries for you** on network errors, `429` and `5xx`, honouring `Retry-After`.
|
|
12
|
+
- **One error type**, `BloomsightError`, with a `code` to branch on. Your key never
|
|
13
|
+
appears in an error.
|
|
14
|
+
- **Typed**, ESM and CommonJS, no runtime dependencies, Node 18+.
|
|
15
|
+
- **Server only.** It refuses to start in a browser, where the key would be exposed.
|
|
16
|
+
For browser forms, use `@bloomscorp/bloomsight.js`.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @bloomscorp/bloomsight-node
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Set up
|
|
25
|
+
|
|
26
|
+
1. In the Bloomsight dashboard, open the template and set its **sending channel** to
|
|
27
|
+
**API only**. A template that takes its recipient from the values (for example
|
|
28
|
+
`{{email}}`) must be API only, so nobody can trigger it from a web page.
|
|
29
|
+
2. In **Project Settings → API keys**, create a key with the **`bloomsight-email`**
|
|
30
|
+
scope. Keep it in your server's environment, never in code or a browser.
|
|
31
|
+
A `blm_test_…` key checks everything and sends nothing: use it in development.
|
|
32
|
+
|
|
33
|
+
## Send an email
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import {Bloomsight, BloomsightError} from '@bloomscorp/bloomsight-node';
|
|
37
|
+
|
|
38
|
+
const bloomsight = new Bloomsight({apiKey: process.env.BLOOMSIGHT_API_KEY!});
|
|
39
|
+
|
|
40
|
+
const send = await bloomsight.sendMail({
|
|
41
|
+
templateId: '…',
|
|
42
|
+
engineId: '…', // the mailbox to send from
|
|
43
|
+
data: {name: 'Asha', email: 'asha@example.com', orderId: 'A-1042', lines: [{item: 'Saree', qty: 2}]},
|
|
44
|
+
idempotencyKey: 'order-A-1042-confirmation', // optional; one is made for you
|
|
45
|
+
});
|
|
46
|
+
// { id, status: 'queued', missingVariables: [] }
|
|
47
|
+
|
|
48
|
+
await bloomsight.getSend(send.id); // queued → sending → sent | failed | unknown
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A send is **queued** and goes out within about a second while the mailbox has budget.
|
|
52
|
+
If the mailbox is at its daily limit, it waits and goes when the budget frees.
|
|
53
|
+
|
|
54
|
+
`unknown` means the connection to the mail provider dropped mid-send, so it cannot be
|
|
55
|
+
known whether it arrived. It is never retried automatically, because a duplicate order
|
|
56
|
+
confirmation is worse than a missing one.
|
|
57
|
+
|
|
58
|
+
**Pass your own `idempotencyKey`** for anything that must go once: the same key and body
|
|
59
|
+
within 24 hours returns the original send (`replayed: true`); the same key with a
|
|
60
|
+
different body throws `IDEMPOTENCY_CONFLICT`.
|
|
61
|
+
|
|
62
|
+
## Send to a list
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const batch = await bloomsight.sendBatch({
|
|
66
|
+
templateId, engineId, name: 'November offer',
|
|
67
|
+
recipients: subscribers.map(s => ({data: {email: s.email, name: s.name}})), // up to 5,000
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await bloomsight.getBatch(batch.id); // progress: queued, sent, failed, …
|
|
71
|
+
await bloomsight.pauseBatch(batch.id);
|
|
72
|
+
await bloomsight.resumeBatch(batch.id);
|
|
73
|
+
await bloomsight.cancelBatch(batch.id);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- Every row is checked first; one bad row refuses the whole batch, and the error's
|
|
77
|
+
`details.faults` lists the rows at fault.
|
|
78
|
+
- Repeated addresses and addresses on the unsubscribe list are dropped.
|
|
79
|
+
- Batches run at the mailbox's bulk share (70% of its daily limit), across days if
|
|
80
|
+
needed, so they never use up the budget your transactional email relies on.
|
|
81
|
+
- Every batch email carries a one-click unsubscribe link (`List-Unsubscribe`). Show it
|
|
82
|
+
in the template too with `{{unsubscribeUrl}}`.
|
|
83
|
+
- Bulk email must be included in the project's Bloomsight plan.
|
|
84
|
+
|
|
85
|
+
## Unsubscribes and budgets
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
await bloomsight.listUnsubscribes({limit: 100});
|
|
89
|
+
await bloomsight.addUnsubscribe('someone@example.com'); // e.g. opted out on your site
|
|
90
|
+
await bloomsight.removeUnsubscribe('someone@example.com'); // only when they opt back in
|
|
91
|
+
|
|
92
|
+
await bloomsight.getLimits(); // each mailbox: limits, used today, paused by its provider?
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Errors
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
try {
|
|
99
|
+
await bloomsight.sendMail({templateId, engineId, data});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof BloomsightError) {
|
|
102
|
+
err.code; // 'TEMPLATE_NOT_FOUND', 'TEMPLATE_PUBLIC_ONLY', 'INVALID_VARIABLES', …
|
|
103
|
+
err.status; // HTTP status, when there was a response
|
|
104
|
+
err.retryAfter; // seconds, when blooms.ai said when to try again
|
|
105
|
+
err.idempotencyKey; // retry with this to stay safe
|
|
106
|
+
err.details; // e.g. { faults } for INVALID_ROWS, { fits } for MONTHLY_LIMIT
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| Code | Meaning |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `INVALID_API_KEY` | The key is missing, revoked or not a Bloomsight key |
|
|
114
|
+
| `KEY_SCOPE` | The key does not list the `bloomsight-email` scope |
|
|
115
|
+
| `TEMPLATE_NOT_FOUND`, `ENGINE_NOT_FOUND` | Not in this key's project |
|
|
116
|
+
| `TEMPLATE_PUBLIC_ONLY` | The template's channel is "Browser forms"; set it to "API only" or "Both" |
|
|
117
|
+
| `INVALID_VARIABLES` | A value name or size is not allowed (100 values, 5,000 characters each, 16 KB in all) |
|
|
118
|
+
| `IDEMPOTENCY_CONFLICT` | The key was used with a different request |
|
|
119
|
+
| `INVALID_ROWS`, `BULK_NOT_IN_PLAN`, `MONTHLY_LIMIT` | Batches |
|
|
120
|
+
| `RATE_LIMITED` | Too many requests; retried for you, thrown when retries run out |
|
|
121
|
+
| `NETWORK_FAILURE`, `TIMEOUT`, `BAD_RESPONSE` | blooms.ai could not be reached or answered oddly, after retries |
|
|
122
|
+
|
|
123
|
+
## Options
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
new Bloomsight({
|
|
127
|
+
apiKey, // required
|
|
128
|
+
baseUrl: 'https://z.bloomscorp.com',
|
|
129
|
+
timeoutMs: 15000, // per attempt
|
|
130
|
+
maxRetries: 3,
|
|
131
|
+
maxRetryWaitMs: 60000, // a longer Retry-After is thrown, not waited out
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request and response types for the Bloomsight email API
|
|
3
|
+
* (blooms.ai documentation/bloomsight/backend-sending.md §4, §7, §8).
|
|
4
|
+
*/
|
|
5
|
+
/** Template values: text, numbers, true/false, null, and lists/objects of those (up to 4 levels). */
|
|
6
|
+
type TemplateValue = string | number | boolean | null | TemplateValue[] | {
|
|
7
|
+
[key: string]: TemplateValue;
|
|
8
|
+
};
|
|
9
|
+
type TemplateData = Record<string, TemplateValue>;
|
|
10
|
+
interface BloomsightOptions {
|
|
11
|
+
/** A project API key with the `bloomsight-email` scope: `blm_live_…` or `blm_test_…`. */
|
|
12
|
+
apiKey: string;
|
|
13
|
+
/** Defaults to `https://z.bloomscorp.com`. */
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
/** Per attempt. Default 15 seconds. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** Retries after the first attempt, for network errors, 429 and 5xx. Default 3. */
|
|
18
|
+
maxRetries?: number;
|
|
19
|
+
/** The longest `Retry-After` the library will wait out by itself. Default 60 seconds. */
|
|
20
|
+
maxRetryWaitMs?: number;
|
|
21
|
+
/** A `fetch` to use instead of the global one (tests, proxies). */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
interface SendMailInput {
|
|
25
|
+
templateId: string;
|
|
26
|
+
engineId: string;
|
|
27
|
+
data?: TemplateData;
|
|
28
|
+
/**
|
|
29
|
+
* Makes the send safe to repeat: the same key and body returns the original send
|
|
30
|
+
* for 24 hours. One is generated when you don't pass one; pass your own (for
|
|
31
|
+
* example `order-A-1042-confirmation`) so even a restarted process cannot send twice.
|
|
32
|
+
*/
|
|
33
|
+
idempotencyKey?: string;
|
|
34
|
+
}
|
|
35
|
+
type SendStatus = 'queued' | 'paused' | 'sending' | 'sent' | 'failed' | 'unknown' | 'cancelled' | 'test';
|
|
36
|
+
interface Send {
|
|
37
|
+
id: string;
|
|
38
|
+
status: SendStatus;
|
|
39
|
+
templateId?: string;
|
|
40
|
+
engineId?: string;
|
|
41
|
+
priority?: 'transactional' | 'bulk';
|
|
42
|
+
attempts?: number;
|
|
43
|
+
recipientCount?: number;
|
|
44
|
+
nextAttemptAt?: string | null;
|
|
45
|
+
errorCode?: string | null;
|
|
46
|
+
error?: string | null;
|
|
47
|
+
createdAt?: string;
|
|
48
|
+
finishedAt?: string | null;
|
|
49
|
+
/** True when sent with a `blm_test_…` key: checked, nothing sent. */
|
|
50
|
+
test?: boolean;
|
|
51
|
+
/** Template values the template uses that this send did not supply. */
|
|
52
|
+
missingVariables?: string[];
|
|
53
|
+
/** True when an Idempotency-Key matched an earlier send and nothing new was queued. */
|
|
54
|
+
replayed?: boolean;
|
|
55
|
+
}
|
|
56
|
+
interface BatchRecipient {
|
|
57
|
+
data: TemplateData;
|
|
58
|
+
}
|
|
59
|
+
interface SendBatchInput {
|
|
60
|
+
templateId: string;
|
|
61
|
+
engineId: string;
|
|
62
|
+
name?: string;
|
|
63
|
+
/** 1 to 5,000 rows. Each row's values follow the `sendMail` rules. */
|
|
64
|
+
recipients: BatchRecipient[];
|
|
65
|
+
idempotencyKey?: string;
|
|
66
|
+
}
|
|
67
|
+
interface BatchProgress {
|
|
68
|
+
queued: number;
|
|
69
|
+
paused: number;
|
|
70
|
+
sending: number;
|
|
71
|
+
sent: number;
|
|
72
|
+
failed: number;
|
|
73
|
+
unknown: number;
|
|
74
|
+
cancelled: number;
|
|
75
|
+
}
|
|
76
|
+
interface Batch {
|
|
77
|
+
id: string | null;
|
|
78
|
+
name?: string;
|
|
79
|
+
status: 'running' | 'paused' | 'cancelled' | 'done' | 'test';
|
|
80
|
+
templateId?: string;
|
|
81
|
+
engineId?: string;
|
|
82
|
+
rows: number;
|
|
83
|
+
accepted: number;
|
|
84
|
+
duplicates: number;
|
|
85
|
+
suppressed: number;
|
|
86
|
+
progress?: BatchProgress;
|
|
87
|
+
expectedFinishAt?: string | null;
|
|
88
|
+
createdAt?: string;
|
|
89
|
+
cancelledAt?: string | null;
|
|
90
|
+
test?: boolean;
|
|
91
|
+
replayed?: boolean;
|
|
92
|
+
}
|
|
93
|
+
interface MailboxLimits {
|
|
94
|
+
engineId: string;
|
|
95
|
+
name: string;
|
|
96
|
+
fromEmail: string;
|
|
97
|
+
/** `null` = not limited. */
|
|
98
|
+
limits: {
|
|
99
|
+
kind: 'workspace' | 'consumer' | 'smtp';
|
|
100
|
+
perMinute: number | null;
|
|
101
|
+
perDay: number | null;
|
|
102
|
+
recipientsPerDay: number | null;
|
|
103
|
+
uniqueRecipientsPerDay: number | null;
|
|
104
|
+
publicPerDay: number | null;
|
|
105
|
+
bulkPerDay: number | null;
|
|
106
|
+
};
|
|
107
|
+
used: {
|
|
108
|
+
minute: number;
|
|
109
|
+
day: number;
|
|
110
|
+
recipients: number;
|
|
111
|
+
unique: number;
|
|
112
|
+
public: number;
|
|
113
|
+
bulk: number;
|
|
114
|
+
};
|
|
115
|
+
heldForMs: number;
|
|
116
|
+
}
|
|
117
|
+
interface Unsubscribe {
|
|
118
|
+
address: string;
|
|
119
|
+
reason: 'unsubscribe' | 'manual' | 'api';
|
|
120
|
+
createdAt: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Internal seam for tests: how the client waits between attempts. */
|
|
124
|
+
type Sleep = (ms: number) => Promise<void>;
|
|
125
|
+
declare class Bloomsight {
|
|
126
|
+
#private;
|
|
127
|
+
constructor(options: BloomsightOptions);
|
|
128
|
+
/** True for a `blm_test_…` key: everything is checked and nothing is sent. */
|
|
129
|
+
get isTestKey(): boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Queue one email. It goes out within about a second when the mailbox has
|
|
132
|
+
* budget; follow it with `getSend(id)`.
|
|
133
|
+
*/
|
|
134
|
+
sendMail(input: SendMailInput): Promise<Send>;
|
|
135
|
+
getSend(id: string): Promise<Send>;
|
|
136
|
+
/** Send one template to many recipients. Checked whole first; one bad row refuses all. */
|
|
137
|
+
sendBatch(input: SendBatchInput): Promise<Batch>;
|
|
138
|
+
getBatch(id: string): Promise<Batch>;
|
|
139
|
+
pauseBatch(id: string): Promise<Batch>;
|
|
140
|
+
resumeBatch(id: string): Promise<Batch>;
|
|
141
|
+
/** Stops every email of the batch that has not started. Cannot be undone. */
|
|
142
|
+
cancelBatch(id: string): Promise<Batch>;
|
|
143
|
+
getLimits(): Promise<MailboxLimits[]>;
|
|
144
|
+
listUnsubscribes(options?: {
|
|
145
|
+
limit?: number;
|
|
146
|
+
before?: string;
|
|
147
|
+
}): Promise<Unsubscribe[]>;
|
|
148
|
+
/** Returns false when the address was already on the list. */
|
|
149
|
+
addUnsubscribe(address: string): Promise<boolean>;
|
|
150
|
+
/** Only when the person has opted back in. Returns false when it was not on the list. */
|
|
151
|
+
removeUnsubscribe(address: string): Promise<boolean>;
|
|
152
|
+
/** @internal test seam */
|
|
153
|
+
_setSleep(sleep: Sleep): void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Every failure is a `BloomsightError`, so callers branch on `code`, never on text.
|
|
158
|
+
* Server codes pass through unchanged (`TEMPLATE_NOT_FOUND`, `KEY_SCOPE`,
|
|
159
|
+
* `IDEMPOTENCY_CONFLICT`, `INVALID_ROWS`, …). The library's own:
|
|
160
|
+
* INVALID_API_KEY the key is missing or not a Bloomsight key
|
|
161
|
+
* BROWSER_NOT_ALLOWED constructed in a browser, where the key would leak
|
|
162
|
+
* NETWORK_FAILURE blooms.ai could not be reached, after the retries
|
|
163
|
+
* TIMEOUT an attempt took longer than `timeoutMs`, after the retries
|
|
164
|
+
* BAD_RESPONSE blooms.ai answered with something that is not its JSON
|
|
165
|
+
*/
|
|
166
|
+
declare class BloomsightError extends Error {
|
|
167
|
+
readonly code: string;
|
|
168
|
+
/** HTTP status, when the failure came from a response. */
|
|
169
|
+
readonly status?: number;
|
|
170
|
+
/** Seconds to wait before trying again, when blooms.ai said so. */
|
|
171
|
+
readonly retryAfter?: number;
|
|
172
|
+
/** The Idempotency-Key the request carried: retry with it to stay safe. */
|
|
173
|
+
readonly idempotencyKey?: string;
|
|
174
|
+
/** Extra detail from the server, e.g. `faults` for INVALID_ROWS, `fits` for MONTHLY_LIMIT. */
|
|
175
|
+
readonly details?: Record<string, unknown>;
|
|
176
|
+
constructor(code: string, message: string, extra?: {
|
|
177
|
+
status?: number;
|
|
178
|
+
retryAfter?: number;
|
|
179
|
+
idempotencyKey?: string;
|
|
180
|
+
details?: Record<string, unknown>;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
declare const VERSION: string;
|
|
185
|
+
|
|
186
|
+
export { type Batch, type BatchProgress, type BatchRecipient, Bloomsight, BloomsightError, type BloomsightOptions, type MailboxLimits, type Send, type SendBatchInput, type SendMailInput, type SendStatus, type TemplateData, type TemplateValue, type Unsubscribe, VERSION };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request and response types for the Bloomsight email API
|
|
3
|
+
* (blooms.ai documentation/bloomsight/backend-sending.md §4, §7, §8).
|
|
4
|
+
*/
|
|
5
|
+
/** Template values: text, numbers, true/false, null, and lists/objects of those (up to 4 levels). */
|
|
6
|
+
type TemplateValue = string | number | boolean | null | TemplateValue[] | {
|
|
7
|
+
[key: string]: TemplateValue;
|
|
8
|
+
};
|
|
9
|
+
type TemplateData = Record<string, TemplateValue>;
|
|
10
|
+
interface BloomsightOptions {
|
|
11
|
+
/** A project API key with the `bloomsight-email` scope: `blm_live_…` or `blm_test_…`. */
|
|
12
|
+
apiKey: string;
|
|
13
|
+
/** Defaults to `https://z.bloomscorp.com`. */
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
/** Per attempt. Default 15 seconds. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** Retries after the first attempt, for network errors, 429 and 5xx. Default 3. */
|
|
18
|
+
maxRetries?: number;
|
|
19
|
+
/** The longest `Retry-After` the library will wait out by itself. Default 60 seconds. */
|
|
20
|
+
maxRetryWaitMs?: number;
|
|
21
|
+
/** A `fetch` to use instead of the global one (tests, proxies). */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
interface SendMailInput {
|
|
25
|
+
templateId: string;
|
|
26
|
+
engineId: string;
|
|
27
|
+
data?: TemplateData;
|
|
28
|
+
/**
|
|
29
|
+
* Makes the send safe to repeat: the same key and body returns the original send
|
|
30
|
+
* for 24 hours. One is generated when you don't pass one; pass your own (for
|
|
31
|
+
* example `order-A-1042-confirmation`) so even a restarted process cannot send twice.
|
|
32
|
+
*/
|
|
33
|
+
idempotencyKey?: string;
|
|
34
|
+
}
|
|
35
|
+
type SendStatus = 'queued' | 'paused' | 'sending' | 'sent' | 'failed' | 'unknown' | 'cancelled' | 'test';
|
|
36
|
+
interface Send {
|
|
37
|
+
id: string;
|
|
38
|
+
status: SendStatus;
|
|
39
|
+
templateId?: string;
|
|
40
|
+
engineId?: string;
|
|
41
|
+
priority?: 'transactional' | 'bulk';
|
|
42
|
+
attempts?: number;
|
|
43
|
+
recipientCount?: number;
|
|
44
|
+
nextAttemptAt?: string | null;
|
|
45
|
+
errorCode?: string | null;
|
|
46
|
+
error?: string | null;
|
|
47
|
+
createdAt?: string;
|
|
48
|
+
finishedAt?: string | null;
|
|
49
|
+
/** True when sent with a `blm_test_…` key: checked, nothing sent. */
|
|
50
|
+
test?: boolean;
|
|
51
|
+
/** Template values the template uses that this send did not supply. */
|
|
52
|
+
missingVariables?: string[];
|
|
53
|
+
/** True when an Idempotency-Key matched an earlier send and nothing new was queued. */
|
|
54
|
+
replayed?: boolean;
|
|
55
|
+
}
|
|
56
|
+
interface BatchRecipient {
|
|
57
|
+
data: TemplateData;
|
|
58
|
+
}
|
|
59
|
+
interface SendBatchInput {
|
|
60
|
+
templateId: string;
|
|
61
|
+
engineId: string;
|
|
62
|
+
name?: string;
|
|
63
|
+
/** 1 to 5,000 rows. Each row's values follow the `sendMail` rules. */
|
|
64
|
+
recipients: BatchRecipient[];
|
|
65
|
+
idempotencyKey?: string;
|
|
66
|
+
}
|
|
67
|
+
interface BatchProgress {
|
|
68
|
+
queued: number;
|
|
69
|
+
paused: number;
|
|
70
|
+
sending: number;
|
|
71
|
+
sent: number;
|
|
72
|
+
failed: number;
|
|
73
|
+
unknown: number;
|
|
74
|
+
cancelled: number;
|
|
75
|
+
}
|
|
76
|
+
interface Batch {
|
|
77
|
+
id: string | null;
|
|
78
|
+
name?: string;
|
|
79
|
+
status: 'running' | 'paused' | 'cancelled' | 'done' | 'test';
|
|
80
|
+
templateId?: string;
|
|
81
|
+
engineId?: string;
|
|
82
|
+
rows: number;
|
|
83
|
+
accepted: number;
|
|
84
|
+
duplicates: number;
|
|
85
|
+
suppressed: number;
|
|
86
|
+
progress?: BatchProgress;
|
|
87
|
+
expectedFinishAt?: string | null;
|
|
88
|
+
createdAt?: string;
|
|
89
|
+
cancelledAt?: string | null;
|
|
90
|
+
test?: boolean;
|
|
91
|
+
replayed?: boolean;
|
|
92
|
+
}
|
|
93
|
+
interface MailboxLimits {
|
|
94
|
+
engineId: string;
|
|
95
|
+
name: string;
|
|
96
|
+
fromEmail: string;
|
|
97
|
+
/** `null` = not limited. */
|
|
98
|
+
limits: {
|
|
99
|
+
kind: 'workspace' | 'consumer' | 'smtp';
|
|
100
|
+
perMinute: number | null;
|
|
101
|
+
perDay: number | null;
|
|
102
|
+
recipientsPerDay: number | null;
|
|
103
|
+
uniqueRecipientsPerDay: number | null;
|
|
104
|
+
publicPerDay: number | null;
|
|
105
|
+
bulkPerDay: number | null;
|
|
106
|
+
};
|
|
107
|
+
used: {
|
|
108
|
+
minute: number;
|
|
109
|
+
day: number;
|
|
110
|
+
recipients: number;
|
|
111
|
+
unique: number;
|
|
112
|
+
public: number;
|
|
113
|
+
bulk: number;
|
|
114
|
+
};
|
|
115
|
+
heldForMs: number;
|
|
116
|
+
}
|
|
117
|
+
interface Unsubscribe {
|
|
118
|
+
address: string;
|
|
119
|
+
reason: 'unsubscribe' | 'manual' | 'api';
|
|
120
|
+
createdAt: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Internal seam for tests: how the client waits between attempts. */
|
|
124
|
+
type Sleep = (ms: number) => Promise<void>;
|
|
125
|
+
declare class Bloomsight {
|
|
126
|
+
#private;
|
|
127
|
+
constructor(options: BloomsightOptions);
|
|
128
|
+
/** True for a `blm_test_…` key: everything is checked and nothing is sent. */
|
|
129
|
+
get isTestKey(): boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Queue one email. It goes out within about a second when the mailbox has
|
|
132
|
+
* budget; follow it with `getSend(id)`.
|
|
133
|
+
*/
|
|
134
|
+
sendMail(input: SendMailInput): Promise<Send>;
|
|
135
|
+
getSend(id: string): Promise<Send>;
|
|
136
|
+
/** Send one template to many recipients. Checked whole first; one bad row refuses all. */
|
|
137
|
+
sendBatch(input: SendBatchInput): Promise<Batch>;
|
|
138
|
+
getBatch(id: string): Promise<Batch>;
|
|
139
|
+
pauseBatch(id: string): Promise<Batch>;
|
|
140
|
+
resumeBatch(id: string): Promise<Batch>;
|
|
141
|
+
/** Stops every email of the batch that has not started. Cannot be undone. */
|
|
142
|
+
cancelBatch(id: string): Promise<Batch>;
|
|
143
|
+
getLimits(): Promise<MailboxLimits[]>;
|
|
144
|
+
listUnsubscribes(options?: {
|
|
145
|
+
limit?: number;
|
|
146
|
+
before?: string;
|
|
147
|
+
}): Promise<Unsubscribe[]>;
|
|
148
|
+
/** Returns false when the address was already on the list. */
|
|
149
|
+
addUnsubscribe(address: string): Promise<boolean>;
|
|
150
|
+
/** Only when the person has opted back in. Returns false when it was not on the list. */
|
|
151
|
+
removeUnsubscribe(address: string): Promise<boolean>;
|
|
152
|
+
/** @internal test seam */
|
|
153
|
+
_setSleep(sleep: Sleep): void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Every failure is a `BloomsightError`, so callers branch on `code`, never on text.
|
|
158
|
+
* Server codes pass through unchanged (`TEMPLATE_NOT_FOUND`, `KEY_SCOPE`,
|
|
159
|
+
* `IDEMPOTENCY_CONFLICT`, `INVALID_ROWS`, …). The library's own:
|
|
160
|
+
* INVALID_API_KEY the key is missing or not a Bloomsight key
|
|
161
|
+
* BROWSER_NOT_ALLOWED constructed in a browser, where the key would leak
|
|
162
|
+
* NETWORK_FAILURE blooms.ai could not be reached, after the retries
|
|
163
|
+
* TIMEOUT an attempt took longer than `timeoutMs`, after the retries
|
|
164
|
+
* BAD_RESPONSE blooms.ai answered with something that is not its JSON
|
|
165
|
+
*/
|
|
166
|
+
declare class BloomsightError extends Error {
|
|
167
|
+
readonly code: string;
|
|
168
|
+
/** HTTP status, when the failure came from a response. */
|
|
169
|
+
readonly status?: number;
|
|
170
|
+
/** Seconds to wait before trying again, when blooms.ai said so. */
|
|
171
|
+
readonly retryAfter?: number;
|
|
172
|
+
/** The Idempotency-Key the request carried: retry with it to stay safe. */
|
|
173
|
+
readonly idempotencyKey?: string;
|
|
174
|
+
/** Extra detail from the server, e.g. `faults` for INVALID_ROWS, `fits` for MONTHLY_LIMIT. */
|
|
175
|
+
readonly details?: Record<string, unknown>;
|
|
176
|
+
constructor(code: string, message: string, extra?: {
|
|
177
|
+
status?: number;
|
|
178
|
+
retryAfter?: number;
|
|
179
|
+
idempotencyKey?: string;
|
|
180
|
+
details?: Record<string, unknown>;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
declare const VERSION: string;
|
|
185
|
+
|
|
186
|
+
export { type Batch, type BatchProgress, type BatchRecipient, Bloomsight, BloomsightError, type BloomsightOptions, type MailboxLimits, type Send, type SendBatchInput, type SendMailInput, type SendStatus, type TemplateData, type TemplateValue, type Unsubscribe, VERSION };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
Bloomsight: () => Bloomsight,
|
|
24
|
+
BloomsightError: () => BloomsightError,
|
|
25
|
+
VERSION: () => VERSION
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
|
|
29
|
+
// src/client.ts
|
|
30
|
+
var import_node_crypto = require("crypto");
|
|
31
|
+
|
|
32
|
+
// src/error.ts
|
|
33
|
+
var BloomsightError = class extends Error {
|
|
34
|
+
code;
|
|
35
|
+
/** HTTP status, when the failure came from a response. */
|
|
36
|
+
status;
|
|
37
|
+
/** Seconds to wait before trying again, when blooms.ai said so. */
|
|
38
|
+
retryAfter;
|
|
39
|
+
/** The Idempotency-Key the request carried: retry with it to stay safe. */
|
|
40
|
+
idempotencyKey;
|
|
41
|
+
/** Extra detail from the server, e.g. `faults` for INVALID_ROWS, `fits` for MONTHLY_LIMIT. */
|
|
42
|
+
details;
|
|
43
|
+
constructor(code, message, extra = {}) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "BloomsightError";
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.status = extra.status;
|
|
48
|
+
this.retryAfter = extra.retryAfter;
|
|
49
|
+
this.idempotencyKey = extra.idempotencyKey;
|
|
50
|
+
this.details = extra.details;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// src/version.ts
|
|
55
|
+
var VERSION = true ? "0.1.0" : "dev";
|
|
56
|
+
|
|
57
|
+
// src/client.ts
|
|
58
|
+
var DEFAULT_BASE = "https://z.bloomscorp.com";
|
|
59
|
+
var KEY_PATTERN = /^blm_(live|test)_[0-9a-f]{64}$/;
|
|
60
|
+
var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
61
|
+
var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
62
|
+
var Bloomsight = class {
|
|
63
|
+
#apiKey;
|
|
64
|
+
#base;
|
|
65
|
+
#timeoutMs;
|
|
66
|
+
#maxRetries;
|
|
67
|
+
#maxRetryWaitMs;
|
|
68
|
+
#fetch;
|
|
69
|
+
#sleep = realSleep;
|
|
70
|
+
constructor(options) {
|
|
71
|
+
if (typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined") {
|
|
72
|
+
throw new BloomsightError(
|
|
73
|
+
"BROWSER_NOT_ALLOWED",
|
|
74
|
+
"@bloomscorp/bloomsight-node runs on servers only: in a browser your API key would be exposed. Use bloomsight.js for browser forms."
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const key = String(options?.apiKey ?? "").trim();
|
|
78
|
+
if (!KEY_PATTERN.test(key)) {
|
|
79
|
+
throw new BloomsightError(
|
|
80
|
+
"INVALID_API_KEY",
|
|
81
|
+
"apiKey must be a Bloomsight API key (blm_live_\u2026 or blm_test_\u2026). Create one with the bloomsight-email scope in Project Settings \u2192 API keys."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
85
|
+
if (typeof f !== "function") {
|
|
86
|
+
throw new BloomsightError("NO_FETCH", "No fetch is available. Use Node.js 18 or later, or pass `fetch` in the options.");
|
|
87
|
+
}
|
|
88
|
+
this.#apiKey = key;
|
|
89
|
+
this.#base = String(options.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "");
|
|
90
|
+
this.#timeoutMs = options.timeoutMs ?? 15e3;
|
|
91
|
+
this.#maxRetries = Math.max(0, options.maxRetries ?? 3);
|
|
92
|
+
this.#maxRetryWaitMs = options.maxRetryWaitMs ?? 6e4;
|
|
93
|
+
this.#fetch = f;
|
|
94
|
+
}
|
|
95
|
+
/** True for a `blm_test_…` key: everything is checked and nothing is sent. */
|
|
96
|
+
get isTestKey() {
|
|
97
|
+
return this.#apiKey.startsWith("blm_test_");
|
|
98
|
+
}
|
|
99
|
+
// ── Sending ──────────────────────────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* Queue one email. It goes out within about a second when the mailbox has
|
|
102
|
+
* budget; follow it with `getSend(id)`.
|
|
103
|
+
*/
|
|
104
|
+
async sendMail(input) {
|
|
105
|
+
const { idempotencyKey, ...body } = input;
|
|
106
|
+
const { data, headers } = await this.#request({
|
|
107
|
+
method: "POST",
|
|
108
|
+
path: "/send",
|
|
109
|
+
body,
|
|
110
|
+
idempotencyKey: idempotencyKey ?? (0, import_node_crypto.randomUUID)()
|
|
111
|
+
});
|
|
112
|
+
return { ...data, replayed: headers.get("idempotent-replayed") === "true" };
|
|
113
|
+
}
|
|
114
|
+
async getSend(id) {
|
|
115
|
+
return (await this.#request({ method: "GET", path: `/sends/${encodeURIComponent(id)}` })).data;
|
|
116
|
+
}
|
|
117
|
+
/** Send one template to many recipients. Checked whole first; one bad row refuses all. */
|
|
118
|
+
async sendBatch(input) {
|
|
119
|
+
const { idempotencyKey, ...body } = input;
|
|
120
|
+
const { data, headers } = await this.#request({
|
|
121
|
+
method: "POST",
|
|
122
|
+
path: "/batches",
|
|
123
|
+
body,
|
|
124
|
+
idempotencyKey: idempotencyKey ?? (0, import_node_crypto.randomUUID)()
|
|
125
|
+
});
|
|
126
|
+
return { ...data, replayed: headers.get("idempotent-replayed") === "true" };
|
|
127
|
+
}
|
|
128
|
+
async getBatch(id) {
|
|
129
|
+
return (await this.#request({ method: "GET", path: `/batches/${encodeURIComponent(id)}` })).data;
|
|
130
|
+
}
|
|
131
|
+
async pauseBatch(id) {
|
|
132
|
+
return (await this.#request({ method: "POST", path: `/batches/${encodeURIComponent(id)}/pause` })).data;
|
|
133
|
+
}
|
|
134
|
+
async resumeBatch(id) {
|
|
135
|
+
return (await this.#request({ method: "POST", path: `/batches/${encodeURIComponent(id)}/resume` })).data;
|
|
136
|
+
}
|
|
137
|
+
/** Stops every email of the batch that has not started. Cannot be undone. */
|
|
138
|
+
async cancelBatch(id) {
|
|
139
|
+
return (await this.#request({ method: "POST", path: `/batches/${encodeURIComponent(id)}/cancel` })).data;
|
|
140
|
+
}
|
|
141
|
+
// ── Budgets and the unsubscribe list ─────────────────────────────────────
|
|
142
|
+
async getLimits() {
|
|
143
|
+
return (await this.#request({ method: "GET", path: "/limits" })).data;
|
|
144
|
+
}
|
|
145
|
+
async listUnsubscribes(options = {}) {
|
|
146
|
+
return (await this.#request({ method: "GET", path: "/unsubscribes", query: options })).data;
|
|
147
|
+
}
|
|
148
|
+
/** Returns false when the address was already on the list. */
|
|
149
|
+
async addUnsubscribe(address) {
|
|
150
|
+
return (await this.#request({ method: "POST", path: "/unsubscribes", body: { address } })).data.added;
|
|
151
|
+
}
|
|
152
|
+
/** Only when the person has opted back in. Returns false when it was not on the list. */
|
|
153
|
+
async removeUnsubscribe(address) {
|
|
154
|
+
try {
|
|
155
|
+
return (await this.#request({ method: "DELETE", path: `/unsubscribes/${encodeURIComponent(address)}` })).data.removed;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err instanceof BloomsightError && err.code === "NOT_ON_LIST") return false;
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ── Transport ────────────────────────────────────────────────────────────
|
|
162
|
+
/** @internal test seam */
|
|
163
|
+
_setSleep(sleep) {
|
|
164
|
+
this.#sleep = sleep;
|
|
165
|
+
}
|
|
166
|
+
#redact(text) {
|
|
167
|
+
return String(text).split(this.#apiKey).join("blm_***");
|
|
168
|
+
}
|
|
169
|
+
async #request(req) {
|
|
170
|
+
const url = new URL(`${this.#base}/api/v1/bloomsight/email${req.path}`);
|
|
171
|
+
for (const [k, v] of Object.entries(req.query ?? {})) if (v !== void 0 && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
172
|
+
const headers = {
|
|
173
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
174
|
+
Accept: "application/json",
|
|
175
|
+
"User-Agent": `bloomsight-node/${VERSION}`
|
|
176
|
+
};
|
|
177
|
+
if (req.body !== void 0) headers["Content-Type"] = "application/json";
|
|
178
|
+
if (req.idempotencyKey) headers["Idempotency-Key"] = req.idempotencyKey;
|
|
179
|
+
const body = req.body === void 0 ? void 0 : JSON.stringify(req.body);
|
|
180
|
+
const retryable = true;
|
|
181
|
+
let attempt = 0;
|
|
182
|
+
for (; ; ) {
|
|
183
|
+
let response;
|
|
184
|
+
try {
|
|
185
|
+
response = await this.#fetch(url, { method: req.method, headers, body, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
const timedOut = cause?.name === "TimeoutError" || cause?.name === "AbortError";
|
|
188
|
+
if (retryable && attempt < this.#maxRetries) {
|
|
189
|
+
await this.#sleep(this.#backoff(attempt++));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
throw new BloomsightError(
|
|
193
|
+
timedOut ? "TIMEOUT" : "NETWORK_FAILURE",
|
|
194
|
+
this.#redact(`${timedOut ? "blooms.ai did not answer in time" : "could not reach blooms.ai"}: ${cause?.message || cause}`),
|
|
195
|
+
{ idempotencyKey: req.idempotencyKey }
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
let parsed = null;
|
|
199
|
+
try {
|
|
200
|
+
parsed = await response.json();
|
|
201
|
+
} catch {
|
|
202
|
+
parsed = null;
|
|
203
|
+
}
|
|
204
|
+
if (response.ok && parsed?.success === true) {
|
|
205
|
+
return { data: parsed.data, headers: response.headers };
|
|
206
|
+
}
|
|
207
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
208
|
+
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : null;
|
|
209
|
+
if (RETRY_STATUSES.has(response.status) && retryable && attempt < this.#maxRetries && (waitMs === null || waitMs <= this.#maxRetryWaitMs)) {
|
|
210
|
+
await this.#sleep(waitMs ?? this.#backoff(attempt));
|
|
211
|
+
attempt++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!parsed || typeof parsed !== "object") {
|
|
215
|
+
throw new BloomsightError(
|
|
216
|
+
"BAD_RESPONSE",
|
|
217
|
+
`blooms.ai answered HTTP ${response.status} without its usual JSON`,
|
|
218
|
+
{ status: response.status, idempotencyKey: req.idempotencyKey }
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const { success, code, message, data, ...rest } = parsed;
|
|
222
|
+
throw new BloomsightError(code || `HTTP_${response.status}`, this.#redact(message || `request failed (HTTP ${response.status})`), {
|
|
223
|
+
status: response.status,
|
|
224
|
+
retryAfter: waitMs === null ? void 0 : retryAfter,
|
|
225
|
+
idempotencyKey: req.idempotencyKey,
|
|
226
|
+
details: Object.keys(rest).length ? rest : void 0
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** 0.5 s, 1 s, 2 s, 4 s … with up to 25% jitter, capped at 10 s. */
|
|
231
|
+
#backoff(attempt) {
|
|
232
|
+
const base = Math.min(1e4, 500 * 2 ** attempt);
|
|
233
|
+
return Math.round(base * (1 + Math.random() * 0.25));
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
237
|
+
0 && (module.exports = {
|
|
238
|
+
Bloomsight,
|
|
239
|
+
BloomsightError,
|
|
240
|
+
VERSION
|
|
241
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
|
|
4
|
+
// src/error.ts
|
|
5
|
+
var BloomsightError = class extends Error {
|
|
6
|
+
code;
|
|
7
|
+
/** HTTP status, when the failure came from a response. */
|
|
8
|
+
status;
|
|
9
|
+
/** Seconds to wait before trying again, when blooms.ai said so. */
|
|
10
|
+
retryAfter;
|
|
11
|
+
/** The Idempotency-Key the request carried: retry with it to stay safe. */
|
|
12
|
+
idempotencyKey;
|
|
13
|
+
/** Extra detail from the server, e.g. `faults` for INVALID_ROWS, `fits` for MONTHLY_LIMIT. */
|
|
14
|
+
details;
|
|
15
|
+
constructor(code, message, extra = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "BloomsightError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.status = extra.status;
|
|
20
|
+
this.retryAfter = extra.retryAfter;
|
|
21
|
+
this.idempotencyKey = extra.idempotencyKey;
|
|
22
|
+
this.details = extra.details;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// src/version.ts
|
|
27
|
+
var VERSION = true ? "0.1.0" : "dev";
|
|
28
|
+
|
|
29
|
+
// src/client.ts
|
|
30
|
+
var DEFAULT_BASE = "https://z.bloomscorp.com";
|
|
31
|
+
var KEY_PATTERN = /^blm_(live|test)_[0-9a-f]{64}$/;
|
|
32
|
+
var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
33
|
+
var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
var Bloomsight = class {
|
|
35
|
+
#apiKey;
|
|
36
|
+
#base;
|
|
37
|
+
#timeoutMs;
|
|
38
|
+
#maxRetries;
|
|
39
|
+
#maxRetryWaitMs;
|
|
40
|
+
#fetch;
|
|
41
|
+
#sleep = realSleep;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
if (typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined") {
|
|
44
|
+
throw new BloomsightError(
|
|
45
|
+
"BROWSER_NOT_ALLOWED",
|
|
46
|
+
"@bloomscorp/bloomsight-node runs on servers only: in a browser your API key would be exposed. Use bloomsight.js for browser forms."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const key = String(options?.apiKey ?? "").trim();
|
|
50
|
+
if (!KEY_PATTERN.test(key)) {
|
|
51
|
+
throw new BloomsightError(
|
|
52
|
+
"INVALID_API_KEY",
|
|
53
|
+
"apiKey must be a Bloomsight API key (blm_live_\u2026 or blm_test_\u2026). Create one with the bloomsight-email scope in Project Settings \u2192 API keys."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
57
|
+
if (typeof f !== "function") {
|
|
58
|
+
throw new BloomsightError("NO_FETCH", "No fetch is available. Use Node.js 18 or later, or pass `fetch` in the options.");
|
|
59
|
+
}
|
|
60
|
+
this.#apiKey = key;
|
|
61
|
+
this.#base = String(options.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "");
|
|
62
|
+
this.#timeoutMs = options.timeoutMs ?? 15e3;
|
|
63
|
+
this.#maxRetries = Math.max(0, options.maxRetries ?? 3);
|
|
64
|
+
this.#maxRetryWaitMs = options.maxRetryWaitMs ?? 6e4;
|
|
65
|
+
this.#fetch = f;
|
|
66
|
+
}
|
|
67
|
+
/** True for a `blm_test_…` key: everything is checked and nothing is sent. */
|
|
68
|
+
get isTestKey() {
|
|
69
|
+
return this.#apiKey.startsWith("blm_test_");
|
|
70
|
+
}
|
|
71
|
+
// ── Sending ──────────────────────────────────────────────────────────────
|
|
72
|
+
/**
|
|
73
|
+
* Queue one email. It goes out within about a second when the mailbox has
|
|
74
|
+
* budget; follow it with `getSend(id)`.
|
|
75
|
+
*/
|
|
76
|
+
async sendMail(input) {
|
|
77
|
+
const { idempotencyKey, ...body } = input;
|
|
78
|
+
const { data, headers } = await this.#request({
|
|
79
|
+
method: "POST",
|
|
80
|
+
path: "/send",
|
|
81
|
+
body,
|
|
82
|
+
idempotencyKey: idempotencyKey ?? randomUUID()
|
|
83
|
+
});
|
|
84
|
+
return { ...data, replayed: headers.get("idempotent-replayed") === "true" };
|
|
85
|
+
}
|
|
86
|
+
async getSend(id) {
|
|
87
|
+
return (await this.#request({ method: "GET", path: `/sends/${encodeURIComponent(id)}` })).data;
|
|
88
|
+
}
|
|
89
|
+
/** Send one template to many recipients. Checked whole first; one bad row refuses all. */
|
|
90
|
+
async sendBatch(input) {
|
|
91
|
+
const { idempotencyKey, ...body } = input;
|
|
92
|
+
const { data, headers } = await this.#request({
|
|
93
|
+
method: "POST",
|
|
94
|
+
path: "/batches",
|
|
95
|
+
body,
|
|
96
|
+
idempotencyKey: idempotencyKey ?? randomUUID()
|
|
97
|
+
});
|
|
98
|
+
return { ...data, replayed: headers.get("idempotent-replayed") === "true" };
|
|
99
|
+
}
|
|
100
|
+
async getBatch(id) {
|
|
101
|
+
return (await this.#request({ method: "GET", path: `/batches/${encodeURIComponent(id)}` })).data;
|
|
102
|
+
}
|
|
103
|
+
async pauseBatch(id) {
|
|
104
|
+
return (await this.#request({ method: "POST", path: `/batches/${encodeURIComponent(id)}/pause` })).data;
|
|
105
|
+
}
|
|
106
|
+
async resumeBatch(id) {
|
|
107
|
+
return (await this.#request({ method: "POST", path: `/batches/${encodeURIComponent(id)}/resume` })).data;
|
|
108
|
+
}
|
|
109
|
+
/** Stops every email of the batch that has not started. Cannot be undone. */
|
|
110
|
+
async cancelBatch(id) {
|
|
111
|
+
return (await this.#request({ method: "POST", path: `/batches/${encodeURIComponent(id)}/cancel` })).data;
|
|
112
|
+
}
|
|
113
|
+
// ── Budgets and the unsubscribe list ─────────────────────────────────────
|
|
114
|
+
async getLimits() {
|
|
115
|
+
return (await this.#request({ method: "GET", path: "/limits" })).data;
|
|
116
|
+
}
|
|
117
|
+
async listUnsubscribes(options = {}) {
|
|
118
|
+
return (await this.#request({ method: "GET", path: "/unsubscribes", query: options })).data;
|
|
119
|
+
}
|
|
120
|
+
/** Returns false when the address was already on the list. */
|
|
121
|
+
async addUnsubscribe(address) {
|
|
122
|
+
return (await this.#request({ method: "POST", path: "/unsubscribes", body: { address } })).data.added;
|
|
123
|
+
}
|
|
124
|
+
/** Only when the person has opted back in. Returns false when it was not on the list. */
|
|
125
|
+
async removeUnsubscribe(address) {
|
|
126
|
+
try {
|
|
127
|
+
return (await this.#request({ method: "DELETE", path: `/unsubscribes/${encodeURIComponent(address)}` })).data.removed;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err instanceof BloomsightError && err.code === "NOT_ON_LIST") return false;
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// ── Transport ────────────────────────────────────────────────────────────
|
|
134
|
+
/** @internal test seam */
|
|
135
|
+
_setSleep(sleep) {
|
|
136
|
+
this.#sleep = sleep;
|
|
137
|
+
}
|
|
138
|
+
#redact(text) {
|
|
139
|
+
return String(text).split(this.#apiKey).join("blm_***");
|
|
140
|
+
}
|
|
141
|
+
async #request(req) {
|
|
142
|
+
const url = new URL(`${this.#base}/api/v1/bloomsight/email${req.path}`);
|
|
143
|
+
for (const [k, v] of Object.entries(req.query ?? {})) if (v !== void 0 && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
144
|
+
const headers = {
|
|
145
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
146
|
+
Accept: "application/json",
|
|
147
|
+
"User-Agent": `bloomsight-node/${VERSION}`
|
|
148
|
+
};
|
|
149
|
+
if (req.body !== void 0) headers["Content-Type"] = "application/json";
|
|
150
|
+
if (req.idempotencyKey) headers["Idempotency-Key"] = req.idempotencyKey;
|
|
151
|
+
const body = req.body === void 0 ? void 0 : JSON.stringify(req.body);
|
|
152
|
+
const retryable = true;
|
|
153
|
+
let attempt = 0;
|
|
154
|
+
for (; ; ) {
|
|
155
|
+
let response;
|
|
156
|
+
try {
|
|
157
|
+
response = await this.#fetch(url, { method: req.method, headers, body, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
const timedOut = cause?.name === "TimeoutError" || cause?.name === "AbortError";
|
|
160
|
+
if (retryable && attempt < this.#maxRetries) {
|
|
161
|
+
await this.#sleep(this.#backoff(attempt++));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
throw new BloomsightError(
|
|
165
|
+
timedOut ? "TIMEOUT" : "NETWORK_FAILURE",
|
|
166
|
+
this.#redact(`${timedOut ? "blooms.ai did not answer in time" : "could not reach blooms.ai"}: ${cause?.message || cause}`),
|
|
167
|
+
{ idempotencyKey: req.idempotencyKey }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
let parsed = null;
|
|
171
|
+
try {
|
|
172
|
+
parsed = await response.json();
|
|
173
|
+
} catch {
|
|
174
|
+
parsed = null;
|
|
175
|
+
}
|
|
176
|
+
if (response.ok && parsed?.success === true) {
|
|
177
|
+
return { data: parsed.data, headers: response.headers };
|
|
178
|
+
}
|
|
179
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
180
|
+
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : null;
|
|
181
|
+
if (RETRY_STATUSES.has(response.status) && retryable && attempt < this.#maxRetries && (waitMs === null || waitMs <= this.#maxRetryWaitMs)) {
|
|
182
|
+
await this.#sleep(waitMs ?? this.#backoff(attempt));
|
|
183
|
+
attempt++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!parsed || typeof parsed !== "object") {
|
|
187
|
+
throw new BloomsightError(
|
|
188
|
+
"BAD_RESPONSE",
|
|
189
|
+
`blooms.ai answered HTTP ${response.status} without its usual JSON`,
|
|
190
|
+
{ status: response.status, idempotencyKey: req.idempotencyKey }
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const { success, code, message, data, ...rest } = parsed;
|
|
194
|
+
throw new BloomsightError(code || `HTTP_${response.status}`, this.#redact(message || `request failed (HTTP ${response.status})`), {
|
|
195
|
+
status: response.status,
|
|
196
|
+
retryAfter: waitMs === null ? void 0 : retryAfter,
|
|
197
|
+
idempotencyKey: req.idempotencyKey,
|
|
198
|
+
details: Object.keys(rest).length ? rest : void 0
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** 0.5 s, 1 s, 2 s, 4 s … with up to 25% jitter, capped at 10 s. */
|
|
203
|
+
#backoff(attempt) {
|
|
204
|
+
const base = Math.min(1e4, 500 * 2 ** attempt);
|
|
205
|
+
return Math.round(base * (1 + Math.random() * 0.25));
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
export {
|
|
209
|
+
Bloomsight,
|
|
210
|
+
BloomsightError,
|
|
211
|
+
VERSION
|
|
212
|
+
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bloomscorp/bloomsight-node",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Send Bloomsight email templates from your Node.js backend, safely: idempotent, retried, typed.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.mjs",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.mts",
|
|
13
|
+
"default": "./dist/index.mjs"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE.md"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"scripts": {
|
|
28
|
+
"lint": "tsc --noEmit",
|
|
29
|
+
"build": "tsup",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
32
|
+
"verify:package": "node scripts/verify-package.mjs",
|
|
33
|
+
"pack:check": "npm pack --dry-run"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"bloomsight",
|
|
37
|
+
"bloomscorp",
|
|
38
|
+
"email",
|
|
39
|
+
"transactional-email",
|
|
40
|
+
"templates",
|
|
41
|
+
"node"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
5
46
|
"license": "MIT",
|
|
6
47
|
"author": "Bloomscorp",
|
|
7
48
|
"homepage": "https://github.com/bloomscorp/bloomsight-node",
|
|
8
|
-
"repository": {
|
|
9
|
-
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "https://github.com/bloomscorp/bloomsight-node"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^20.14.0",
|
|
55
|
+
"tsup": "^8.1.0",
|
|
56
|
+
"typescript": "^5.5.0",
|
|
57
|
+
"vitest": "^1.6.0"
|
|
58
|
+
}
|
|
10
59
|
}
|