bul-email 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +105 -2
- package/dist/errors.d.ts +55 -0
- package/dist/errors.js +79 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.js +185 -0
- package/dist/types.d.ts +339 -0
- package/dist/types.js +4 -0
- package/dist/webhooks.d.ts +24 -0
- package/dist/webhooks.js +50 -0
- package/package.json +41 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BUL
|
|
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,3 +1,106 @@
|
|
|
1
|
-
#
|
|
1
|
+
# bul-email
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<div dir="rtl">
|
|
4
|
+
|
|
5
|
+
הספרייה הרשמית של **בול** — Email API למיילים טרנזקציוניים ולדיוור — ל-JavaScript ול-TypeScript.
|
|
6
|
+
עובדת ב-Node 18 ומעלה, ב-Bun, ב-Deno ובסביבות edge. התיעוד המלא: [bul.friman.app/docs](https://bul.friman.app/docs).
|
|
7
|
+
|
|
8
|
+
## התקנה
|
|
9
|
+
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install bul-email
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
<div dir="rtl">
|
|
17
|
+
|
|
18
|
+
## שליחת מייל
|
|
19
|
+
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Bul } from "bul-email";
|
|
24
|
+
|
|
25
|
+
const bul = new Bul({ apiKey: process.env.BUL_API_KEY! });
|
|
26
|
+
|
|
27
|
+
const { id } = await bul.emails.send(
|
|
28
|
+
{ from: "Acme <hi@acme.co.il>", to: ["dana@example.com"], subject: "שלום", html: "<p>ההזמנה שלך התקבלה</p>" },
|
|
29
|
+
{ idempotencyKey: "order-1234" },
|
|
30
|
+
);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
<div dir="rtl">
|
|
34
|
+
|
|
35
|
+
## מה יש בספרייה
|
|
36
|
+
|
|
37
|
+
- **כל נקודות הקצה:** `status`, `stats`, `usage`, `emails.{send,batch,get,list,listAll}`, `events.{list,iterate}`,
|
|
38
|
+
`campaigns.{create,get}`, `domains.{list,create,get,delete}`, `webhooks.{list,create,get,delete,test}`,
|
|
39
|
+
`suppressions.{list,add,import,remove}`, `recipients.proof`.
|
|
40
|
+
- **ניסיונות חוזרים:** שגיאות רשת, timeout, 429 (לפי Retry-After), 500/502/503/504 ו-409 `already_processing` — עם המתנה הולכת וגדלה
|
|
41
|
+
(`maxRetries`, ברירת מחדל 3). לכל שליחה `Idempotency-Key` אחד, שחוזר בכל ניסיון — ניסיון חוזר אף פעם לא שולח פעמיים.
|
|
42
|
+
- **שגיאות ברורות:** כל שגיאה מה-API היא `BulError` (או תת-מחלקה) עם `code` ו-`status`. 409 `route_paused` → `RoutePausedError`
|
|
43
|
+
(`reasonCode`, `returns[]`) — שום דבר לא התקבל; שולחים בדרך אחרת ומדווחים עם `recipients.proof`.
|
|
44
|
+
- **Webhooks:** `verifyWebhookSignature` / `constructWebhookEvent` — HMAC-SHA256, סבילות של 5 דקות, השוואה בזמן קבוע.
|
|
45
|
+
`BulEvent` מוקלד לפי `event`.
|
|
46
|
+
|
|
47
|
+
## מעבר מ-Resend
|
|
48
|
+
|
|
49
|
+
כמעט אותו קוד: מחליפים את ה-import ואת המפתח. המדריך המלא: [מעבר מ-Resend לבול](https://bul.friman.app/docs#migrate-from-resend).
|
|
50
|
+
|
|
51
|
+
## רישיון
|
|
52
|
+
|
|
53
|
+
MIT
|
|
54
|
+
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## English
|
|
60
|
+
|
|
61
|
+
The official JavaScript / TypeScript client for **BUL** — an Email API for transactional email and campaigns.
|
|
62
|
+
Works on Node 18+, Bun, Deno and edge runtimes. Full documentation: [bul.friman.app/docs](https://bul.friman.app/docs).
|
|
63
|
+
|
|
64
|
+
### Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install bul-email
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Send an email
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { Bul } from "bul-email";
|
|
74
|
+
|
|
75
|
+
const bul = new Bul({ apiKey: process.env.BUL_API_KEY! });
|
|
76
|
+
|
|
77
|
+
const { id } = await bul.emails.send(
|
|
78
|
+
{ from: "Acme <hi@acme.co.il>", to: ["dana@example.com"], subject: "Hello", html: "<p>Your order is confirmed</p>" },
|
|
79
|
+
{ idempotencyKey: "order-1234" },
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### What's included
|
|
84
|
+
|
|
85
|
+
- **Every endpoint**: `status`, `stats`, `usage`, `emails.{send,batch,get,list,listAll}`, `events.{list,iterate}`,
|
|
86
|
+
`campaigns.{create,get}`, `domains.{list,create,get,delete}`, `webhooks.{list,create,get,delete,test}`,
|
|
87
|
+
`suppressions.{list,add,import,remove}`, `recipients.proof`.
|
|
88
|
+
- **Retries**: network errors, timeouts, 429 (honours Retry-After), 500/502/503/504 and 409 `already_processing`, with exponential
|
|
89
|
+
backoff (`maxRetries`, default 3). Each send gets one `Idempotency-Key`, reused on every retry — a retry never sends twice.
|
|
90
|
+
- **Clear errors**: every API error is a `BulError` (or a subclass) with `code` and `status`. 409 `route_paused` → `RoutePausedError` (`reasonCode`,
|
|
91
|
+
`returns[]`) — nothing was accepted; send another way and report it with `recipients.proof`.
|
|
92
|
+
- **Webhooks**: `verifyWebhookSignature` / `constructWebhookEvent` — HMAC-SHA256, 5-minute tolerance, constant-time comparison.
|
|
93
|
+
`BulEvent` is typed by `event`.
|
|
94
|
+
|
|
95
|
+
### Migrating from Resend
|
|
96
|
+
|
|
97
|
+
Almost the same code: change the import and the key. Full guide: [Migrating from Resend](https://bul.friman.app/docs#migrate-from-resend).
|
|
98
|
+
|
|
99
|
+
### Changelog
|
|
100
|
+
|
|
101
|
+
- **0.1.1** — documentation (this changelog) and the version string. No behavior changes. The first release published straight from the build pipeline, with a verified publisher.
|
|
102
|
+
- **0.1.0** — first release.
|
|
103
|
+
|
|
104
|
+
### License
|
|
105
|
+
|
|
106
|
+
MIT
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Errors thrown by the Bul client. `code` is the stable API error code; `message` is the API's (Hebrew) explanation. */
|
|
2
|
+
export declare class BulError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly code: string;
|
|
5
|
+
/** The full `error` object from the response (extra fields such as `limit`, `fields`, `returns`). */
|
|
6
|
+
readonly details: Record<string, unknown>;
|
|
7
|
+
constructor(status: number, code: string, message: string, details?: Record<string, unknown>);
|
|
8
|
+
}
|
|
9
|
+
/** 401 — missing, wrong or revoked API key. */
|
|
10
|
+
export declare class AuthenticationError extends BulError {
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
/** 403 — the key's scope is not allowed (forbidden), or a tier limit (tier_domain_limit). */
|
|
14
|
+
export declare class PermissionError extends BulError {
|
|
15
|
+
name: string;
|
|
16
|
+
}
|
|
17
|
+
/** 429 — rate limit or daily tier limit, after the client's own retries were exhausted. */
|
|
18
|
+
export declare class RateLimitError extends BulError {
|
|
19
|
+
readonly retryAfterSeconds: number | null;
|
|
20
|
+
name: string;
|
|
21
|
+
constructor(status: number, code: string, message: string, details: Record<string, unknown>, retryAfterSeconds: number | null);
|
|
22
|
+
}
|
|
23
|
+
/** 423 — account frozen (account_frozen) or sending blocked (sending_blocked). Reads still work. */
|
|
24
|
+
export declare class AccountBlockedError extends BulError {
|
|
25
|
+
name: string;
|
|
26
|
+
}
|
|
27
|
+
export interface ReturnedMessage {
|
|
28
|
+
/** Position of the item in the batch, or of the recipient in the request. */
|
|
29
|
+
index: number;
|
|
30
|
+
recipient: string;
|
|
31
|
+
/** Report the outcome after resending with your provider: client.recipients.proof({ return_ref, ... }). */
|
|
32
|
+
return_ref: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 409 route_paused — nothing from the request was accepted; it is safe to send it with another provider.
|
|
36
|
+
* `reasonCode`: route_paused | recipient_not_proven | direct_unavailable.
|
|
37
|
+
*/
|
|
38
|
+
export declare class RoutePausedError extends BulError {
|
|
39
|
+
name: string;
|
|
40
|
+
readonly reasonCode: string;
|
|
41
|
+
readonly returns: ReturnedMessage[];
|
|
42
|
+
readonly safeToResend: true;
|
|
43
|
+
constructor(status: number, message: string, details: Record<string, unknown>);
|
|
44
|
+
}
|
|
45
|
+
/** The request could not reach Bul (network error / timeout) after all retries. */
|
|
46
|
+
export declare class ConnectionError extends Error {
|
|
47
|
+
readonly cause?: unknown | undefined;
|
|
48
|
+
name: string;
|
|
49
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
50
|
+
}
|
|
51
|
+
/** Webhook signature missing, invalid or too old. */
|
|
52
|
+
export declare class WebhookVerificationError extends Error {
|
|
53
|
+
name: string;
|
|
54
|
+
}
|
|
55
|
+
export declare function errorFromResponse(status: number, body: unknown, retryAfterSeconds: number | null): BulError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Errors thrown by the Bul client. `code` is the stable API error code; `message` is the API's (Hebrew) explanation. */
|
|
2
|
+
export class BulError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
/** The full `error` object from the response (extra fields such as `limit`, `fields`, `returns`). */
|
|
6
|
+
details;
|
|
7
|
+
constructor(status, code, message, details = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "BulError";
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** 401 — missing, wrong or revoked API key. */
|
|
16
|
+
export class AuthenticationError extends BulError {
|
|
17
|
+
name = "AuthenticationError";
|
|
18
|
+
}
|
|
19
|
+
/** 403 — the key's scope is not allowed (forbidden), or a tier limit (tier_domain_limit). */
|
|
20
|
+
export class PermissionError extends BulError {
|
|
21
|
+
name = "PermissionError";
|
|
22
|
+
}
|
|
23
|
+
/** 429 — rate limit or daily tier limit, after the client's own retries were exhausted. */
|
|
24
|
+
export class RateLimitError extends BulError {
|
|
25
|
+
retryAfterSeconds;
|
|
26
|
+
name = "RateLimitError";
|
|
27
|
+
constructor(status, code, message, details, retryAfterSeconds) {
|
|
28
|
+
super(status, code, message, details);
|
|
29
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** 423 — account frozen (account_frozen) or sending blocked (sending_blocked). Reads still work. */
|
|
33
|
+
export class AccountBlockedError extends BulError {
|
|
34
|
+
name = "AccountBlockedError";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 409 route_paused — nothing from the request was accepted; it is safe to send it with another provider.
|
|
38
|
+
* `reasonCode`: route_paused | recipient_not_proven | direct_unavailable.
|
|
39
|
+
*/
|
|
40
|
+
export class RoutePausedError extends BulError {
|
|
41
|
+
name = "RoutePausedError";
|
|
42
|
+
reasonCode;
|
|
43
|
+
returns;
|
|
44
|
+
safeToResend = true;
|
|
45
|
+
constructor(status, message, details) {
|
|
46
|
+
super(status, "route_paused", message, details);
|
|
47
|
+
this.reasonCode = typeof details["reason_code"] === "string" ? details["reason_code"] : "route_paused";
|
|
48
|
+
this.returns = Array.isArray(details["returns"]) ? details["returns"] : [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The request could not reach Bul (network error / timeout) after all retries. */
|
|
52
|
+
export class ConnectionError extends Error {
|
|
53
|
+
cause;
|
|
54
|
+
name = "ConnectionError";
|
|
55
|
+
constructor(message, cause) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.cause = cause;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Webhook signature missing, invalid or too old. */
|
|
61
|
+
export class WebhookVerificationError extends Error {
|
|
62
|
+
name = "WebhookVerificationError";
|
|
63
|
+
}
|
|
64
|
+
export function errorFromResponse(status, body, retryAfterSeconds) {
|
|
65
|
+
const e = (body?.error ?? {});
|
|
66
|
+
const code = typeof e["code"] === "string" ? e["code"] : `http_${status}`;
|
|
67
|
+
const message = typeof e["message"] === "string" ? e["message"] : `Bul API error ${status}`;
|
|
68
|
+
if (status === 409 && code === "route_paused")
|
|
69
|
+
return new RoutePausedError(status, message, e);
|
|
70
|
+
if (status === 401)
|
|
71
|
+
return new AuthenticationError(status, code, message, e);
|
|
72
|
+
if (status === 403)
|
|
73
|
+
return new PermissionError(status, code, message, e);
|
|
74
|
+
if (status === 429)
|
|
75
|
+
return new RateLimitError(status, code, message, e, retryAfterSeconds);
|
|
76
|
+
if (status === 423)
|
|
77
|
+
return new AccountBlockedError(status, code, message, e);
|
|
78
|
+
return new BulError(status, code, message, e);
|
|
79
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type * as T from "./types";
|
|
2
|
+
export * from "./errors";
|
|
3
|
+
export * from "./types";
|
|
4
|
+
export { constructWebhookEvent, verifyWebhookSignature, type VerifyOptions } from "./webhooks";
|
|
5
|
+
/**
|
|
6
|
+
* Bul — official JavaScript / TypeScript client (https://bul.friman.app/docs).
|
|
7
|
+
*
|
|
8
|
+
* const bul = new Bul({ apiKey: process.env.BUL_API_KEY! });
|
|
9
|
+
* const { id } = await bul.emails.send({ from: "Acme <hi@acme.co.il>", to: ["dana@example.com"], subject: "שלום", html: "<p>…</p>" });
|
|
10
|
+
*
|
|
11
|
+
* Retries: network errors, timeouts, 429 (honours Retry-After), 500/502/503/504 and 409 already_processing are retried with
|
|
12
|
+
* exponential backoff — but a POST is retried after a network error / 5xx only when it is idempotent: sends carry an
|
|
13
|
+
* `Idempotency-Key` that the client generates once per call and reuses on every retry, so a retry never sends twice.
|
|
14
|
+
* 409 route_paused is never retried: it throws RoutePausedError (nothing was accepted; `returns[].return_ref` for reporting).
|
|
15
|
+
*/
|
|
16
|
+
export interface BulOptions {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
/** Default https://bul.friman.app */
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
/** Retries after the first attempt. Default 3. */
|
|
21
|
+
maxRetries?: number;
|
|
22
|
+
/** Per-attempt timeout. Default 30 seconds. */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
/** Custom fetch (tests, proxies, edge runtimes). */
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
/** For tests: replaces the wait between retries. */
|
|
27
|
+
sleep?: (ms: number) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
type Query = Record<string, string | number | boolean | string[] | undefined>;
|
|
30
|
+
interface RequestSpec {
|
|
31
|
+
method: "GET" | "POST" | "DELETE";
|
|
32
|
+
path: string;
|
|
33
|
+
query?: Query;
|
|
34
|
+
body?: unknown;
|
|
35
|
+
idempotencyKey?: string;
|
|
36
|
+
/** POST that is safe to repeat even without an Idempotency-Key (the API deduplicates). */
|
|
37
|
+
idempotent?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare const SDK_VERSION = "0.1.1";
|
|
40
|
+
export declare class Bul {
|
|
41
|
+
private readonly apiKey;
|
|
42
|
+
private readonly baseUrl;
|
|
43
|
+
private readonly maxRetries;
|
|
44
|
+
private readonly timeoutMs;
|
|
45
|
+
private readonly fetchImpl;
|
|
46
|
+
private readonly sleep;
|
|
47
|
+
constructor(opts: BulOptions);
|
|
48
|
+
/** Low-level request — prefer the typed methods below. */
|
|
49
|
+
request<R>(spec: RequestSpec): Promise<R>;
|
|
50
|
+
/** GET /v1/status — key, sending state, rate limit, domains. Any key scope. */
|
|
51
|
+
status(): Promise<T.AccountStatus>;
|
|
52
|
+
/** GET /v1/stats */
|
|
53
|
+
stats(): Promise<T.Stats>;
|
|
54
|
+
/** GET /v1/usage — per month, or per campaign. */
|
|
55
|
+
usage(params?: {
|
|
56
|
+
group?: "campaign";
|
|
57
|
+
}): Promise<{
|
|
58
|
+
data: Record<string, unknown>[];
|
|
59
|
+
} & Record<string, unknown>>;
|
|
60
|
+
readonly emails: {
|
|
61
|
+
/** POST /v1/emails — one recipient. `idempotencyKey` defaults to a new UUID (reused across retries). */
|
|
62
|
+
send: (input: T.SendEmailInput, opts?: {
|
|
63
|
+
idempotencyKey?: string;
|
|
64
|
+
}) => Promise<T.SendEmailResponse>;
|
|
65
|
+
/** POST /v1/emails/batch — an object (same message, up to 100 recipients) or an array of up to 100 different messages. */
|
|
66
|
+
batch: <I extends T.BatchObjectInput | T.BatchItemInput[]>(input: I, opts?: {
|
|
67
|
+
idempotencyKey?: string;
|
|
68
|
+
mode?: "partial" | "atomic";
|
|
69
|
+
}) => Promise<I extends T.BatchItemInput[] ? T.BatchItemsResponse : T.BatchObjectResponse>;
|
|
70
|
+
/** GET /v1/emails/{id} — `includeHtml` adds html and text. */
|
|
71
|
+
get: (id: string, opts?: {
|
|
72
|
+
includeHtml?: boolean;
|
|
73
|
+
}) => Promise<T.Email>;
|
|
74
|
+
/** GET /v1/emails — one page, newest first. */
|
|
75
|
+
list: (params?: T.ListEmailsParams) => Promise<T.Page<T.Email>>;
|
|
76
|
+
/** All pages (async iterator). */
|
|
77
|
+
listAll: (params?: Omit<T.ListEmailsParams, "cursor">) => AsyncGenerator<T.Email>;
|
|
78
|
+
};
|
|
79
|
+
readonly events: {
|
|
80
|
+
/** GET /v1/events — events in the order Bul received them (the same shape as webhooks). */
|
|
81
|
+
list: (params?: T.ListEventsParams) => Promise<T.Page<T.BulEvent>>;
|
|
82
|
+
/** All pages from `since` (async iterator). Save the last `next_cursor` to resume later. */
|
|
83
|
+
iterate: (params?: Omit<T.ListEventsParams, "cursor">) => AsyncGenerator<T.BulEvent>;
|
|
84
|
+
};
|
|
85
|
+
readonly campaigns: {
|
|
86
|
+
/** POST /v1/campaigns — up to 10,000 recipients (kind defaults to marketing). */
|
|
87
|
+
create: (input: T.CampaignInput, opts?: {
|
|
88
|
+
idempotencyKey?: string;
|
|
89
|
+
}) => Promise<T.Campaign>;
|
|
90
|
+
get: (id: string, opts?: {
|
|
91
|
+
includeHtml?: boolean;
|
|
92
|
+
}) => Promise<T.Campaign>;
|
|
93
|
+
};
|
|
94
|
+
readonly domains: {
|
|
95
|
+
list: () => Promise<{
|
|
96
|
+
data: T.Domain[];
|
|
97
|
+
}>;
|
|
98
|
+
/** POST /v1/domains (full key). */
|
|
99
|
+
create: (domain: string) => Promise<T.Domain>;
|
|
100
|
+
get: (id: string) => Promise<T.Domain>;
|
|
101
|
+
delete: (id: string) => Promise<void>;
|
|
102
|
+
};
|
|
103
|
+
readonly webhooks: {
|
|
104
|
+
list: () => Promise<{
|
|
105
|
+
data: T.Webhook[];
|
|
106
|
+
}>;
|
|
107
|
+
/** POST /v1/webhooks (full key). The `signing_secret` is returned only here — store it. */
|
|
108
|
+
create: (input: {
|
|
109
|
+
url: string;
|
|
110
|
+
events: T.WebhookEventName[];
|
|
111
|
+
}) => Promise<T.CreatedWebhook>;
|
|
112
|
+
get: (id: string) => Promise<T.Webhook>;
|
|
113
|
+
delete: (id: string) => Promise<void>;
|
|
114
|
+
/** POST /v1/webhooks/{id}/test — a signed test event (optionally of a chosen type). */
|
|
115
|
+
test: (id: string, spec?: T.WebhookTestSpec) => Promise<T.WebhookTestResult>;
|
|
116
|
+
};
|
|
117
|
+
readonly suppressions: {
|
|
118
|
+
list: (params?: {
|
|
119
|
+
limit?: number;
|
|
120
|
+
offset?: number;
|
|
121
|
+
}) => Promise<{
|
|
122
|
+
data: T.Suppression[];
|
|
123
|
+
total: number;
|
|
124
|
+
limit: number;
|
|
125
|
+
offset: number;
|
|
126
|
+
}>;
|
|
127
|
+
add: (input: {
|
|
128
|
+
recipient: string;
|
|
129
|
+
reason?: T.SuppressionReason;
|
|
130
|
+
}) => Promise<T.Suppression | {
|
|
131
|
+
recipient: string;
|
|
132
|
+
already_suppressed: true;
|
|
133
|
+
}>;
|
|
134
|
+
import: (input: {
|
|
135
|
+
recipients: string[];
|
|
136
|
+
reason?: T.SuppressionReason;
|
|
137
|
+
}) => Promise<{
|
|
138
|
+
added: number;
|
|
139
|
+
already_suppressed: number;
|
|
140
|
+
invalid: string[];
|
|
141
|
+
}>;
|
|
142
|
+
/** DELETE /v1/suppressions/{id or email} (full key). */
|
|
143
|
+
remove: (idOrEmail: string) => Promise<void>;
|
|
144
|
+
};
|
|
145
|
+
readonly recipients: {
|
|
146
|
+
/**
|
|
147
|
+
* POST /v1/recipients/proof — report the outcome of a message Bul returned to you (`return_ref` from RoutePausedError.returns
|
|
148
|
+
* or from a `withdrawn` event) after you sent it with your own provider. Idempotent by return_ref + outcome.
|
|
149
|
+
*/
|
|
150
|
+
proof: (input: T.ProofInput) => Promise<T.ProofResponse>;
|
|
151
|
+
};
|
|
152
|
+
private paginate;
|
|
153
|
+
}
|
|
154
|
+
export default Bul;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { BulError, ConnectionError, errorFromResponse } from "./errors";
|
|
2
|
+
export * from "./errors";
|
|
3
|
+
export * from "./types";
|
|
4
|
+
export { constructWebhookEvent, verifyWebhookSignature } from "./webhooks";
|
|
5
|
+
const RETRY_STATUS = new Set([500, 502, 503, 504]);
|
|
6
|
+
export const SDK_VERSION = "0.1.1";
|
|
7
|
+
function newKey() {
|
|
8
|
+
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
9
|
+
? crypto.randomUUID()
|
|
10
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
11
|
+
}
|
|
12
|
+
function retryAfterSeconds(h) {
|
|
13
|
+
if (!h)
|
|
14
|
+
return null;
|
|
15
|
+
if (/^\d+$/.test(h.trim()))
|
|
16
|
+
return Number(h.trim());
|
|
17
|
+
const t = Date.parse(h);
|
|
18
|
+
return Number.isNaN(t) ? null : Math.max(0, Math.ceil((t - Date.now()) / 1000));
|
|
19
|
+
}
|
|
20
|
+
export class Bul {
|
|
21
|
+
apiKey;
|
|
22
|
+
baseUrl;
|
|
23
|
+
maxRetries;
|
|
24
|
+
timeoutMs;
|
|
25
|
+
fetchImpl;
|
|
26
|
+
sleep;
|
|
27
|
+
constructor(opts) {
|
|
28
|
+
if (!opts?.apiKey)
|
|
29
|
+
throw new Error("Bul: apiKey is required");
|
|
30
|
+
this.apiKey = opts.apiKey;
|
|
31
|
+
this.baseUrl = (opts.baseUrl ?? "https://bul.friman.app").replace(/\/$/, "");
|
|
32
|
+
this.maxRetries = opts.maxRetries ?? 3;
|
|
33
|
+
this.timeoutMs = opts.timeoutMs ?? 30_000;
|
|
34
|
+
this.fetchImpl = opts.fetch ?? ((...a) => fetch(...a));
|
|
35
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
36
|
+
}
|
|
37
|
+
/** Low-level request — prefer the typed methods below. */
|
|
38
|
+
async request(spec) {
|
|
39
|
+
const url = new URL(`${this.baseUrl}${spec.path}`);
|
|
40
|
+
for (const [k, v] of Object.entries(spec.query ?? {})) {
|
|
41
|
+
if (v === undefined)
|
|
42
|
+
continue;
|
|
43
|
+
url.searchParams.set(k, Array.isArray(v) ? v.join(",") : String(v));
|
|
44
|
+
}
|
|
45
|
+
const headers = {
|
|
46
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
47
|
+
accept: "application/json",
|
|
48
|
+
"user-agent": `bul-js/${SDK_VERSION}`,
|
|
49
|
+
};
|
|
50
|
+
if (spec.body !== undefined)
|
|
51
|
+
headers["content-type"] = "application/json";
|
|
52
|
+
if (spec.idempotencyKey)
|
|
53
|
+
headers["idempotency-key"] = spec.idempotencyKey;
|
|
54
|
+
const safeToRepeat = spec.method !== "POST" || !!spec.idempotencyKey || !!spec.idempotent;
|
|
55
|
+
let lastError;
|
|
56
|
+
for (let attempt = 0;; attempt++) {
|
|
57
|
+
const backoff = (min = 0) => Math.max(min, Math.min(30_000, 500 * 2 ** attempt) * (0.75 + Math.random() * 0.5));
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await this.fetchImpl(url.toString(), {
|
|
61
|
+
method: spec.method,
|
|
62
|
+
headers,
|
|
63
|
+
...(spec.body !== undefined ? { body: JSON.stringify(spec.body) } : {}),
|
|
64
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
lastError = error;
|
|
69
|
+
if (safeToRepeat && attempt < this.maxRetries) {
|
|
70
|
+
await this.sleep(backoff());
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
throw new ConnectionError(`Bul: request failed (${spec.method} ${spec.path}): ${error?.message ?? error}`, error);
|
|
74
|
+
}
|
|
75
|
+
if (res.status === 204)
|
|
76
|
+
return undefined;
|
|
77
|
+
const text = await res.text().catch(() => "");
|
|
78
|
+
// A non-JSON body (e.g. an HTML 502 page from a proxy) must not throw SyntaxError — fall through to status handling.
|
|
79
|
+
let body = null;
|
|
80
|
+
try {
|
|
81
|
+
body = text ? JSON.parse(text) : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
body = res.ok ? text : { error: { code: `http_${res.status}`, message: text.slice(0, 200) || `HTTP ${res.status}` } };
|
|
85
|
+
}
|
|
86
|
+
if (res.ok)
|
|
87
|
+
return body;
|
|
88
|
+
const ra = retryAfterSeconds(res.headers.get("retry-after"));
|
|
89
|
+
const code = body?.error?.code;
|
|
90
|
+
const retryable = res.status === 429 ||
|
|
91
|
+
(res.status === 409 && code === "already_processing") ||
|
|
92
|
+
(RETRY_STATUS.has(res.status) && safeToRepeat);
|
|
93
|
+
if (retryable && attempt < this.maxRetries) {
|
|
94
|
+
await this.sleep(ra !== null ? ra * 1000 : backoff(res.status === 409 ? 1000 : 0));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
throw errorFromResponse(res.status, body, ra);
|
|
98
|
+
}
|
|
99
|
+
// unreachable
|
|
100
|
+
throw lastError instanceof BulError ? lastError : new ConnectionError("Bul: request failed");
|
|
101
|
+
}
|
|
102
|
+
/** GET /v1/status — key, sending state, rate limit, domains. Any key scope. */
|
|
103
|
+
status() {
|
|
104
|
+
return this.request({ method: "GET", path: "/v1/status" });
|
|
105
|
+
}
|
|
106
|
+
/** GET /v1/stats */
|
|
107
|
+
stats() {
|
|
108
|
+
return this.request({ method: "GET", path: "/v1/stats" });
|
|
109
|
+
}
|
|
110
|
+
/** GET /v1/usage — per month, or per campaign. */
|
|
111
|
+
usage(params = {}) {
|
|
112
|
+
return this.request({ method: "GET", path: "/v1/usage", query: params });
|
|
113
|
+
}
|
|
114
|
+
emails = {
|
|
115
|
+
/** POST /v1/emails — one recipient. `idempotencyKey` defaults to a new UUID (reused across retries). */
|
|
116
|
+
send: (input, opts = {}) => this.request({ method: "POST", path: "/v1/emails", body: input, idempotencyKey: opts.idempotencyKey ?? newKey() }),
|
|
117
|
+
/** POST /v1/emails/batch — an object (same message, up to 100 recipients) or an array of up to 100 different messages. */
|
|
118
|
+
batch: (input, opts = {}) => this.request({
|
|
119
|
+
method: "POST",
|
|
120
|
+
path: "/v1/emails/batch",
|
|
121
|
+
query: { mode: opts.mode },
|
|
122
|
+
body: input,
|
|
123
|
+
idempotencyKey: opts.idempotencyKey ?? newKey(),
|
|
124
|
+
}),
|
|
125
|
+
/** GET /v1/emails/{id} — `includeHtml` adds html and text. */
|
|
126
|
+
get: (id, opts = {}) => this.request({ method: "GET", path: `/v1/emails/${encodeURIComponent(id)}`, query: { include: opts.includeHtml ? "html" : undefined } }),
|
|
127
|
+
/** GET /v1/emails — one page, newest first. */
|
|
128
|
+
list: (params = {}) => this.request({ method: "GET", path: "/v1/emails", query: params }),
|
|
129
|
+
/** All pages (async iterator). */
|
|
130
|
+
listAll: (params = {}) => this.paginate((cursor) => this.emails.list({ ...params, ...(cursor ? { cursor } : {}) })),
|
|
131
|
+
};
|
|
132
|
+
events = {
|
|
133
|
+
/** GET /v1/events — events in the order Bul received them (the same shape as webhooks). */
|
|
134
|
+
list: (params = {}) => this.request({ method: "GET", path: "/v1/events", query: params }),
|
|
135
|
+
/** All pages from `since` (async iterator). Save the last `next_cursor` to resume later. */
|
|
136
|
+
iterate: (params = {}) => this.paginate((cursor) => this.events.list({ ...params, ...(cursor ? { cursor } : {}) })),
|
|
137
|
+
};
|
|
138
|
+
campaigns = {
|
|
139
|
+
/** POST /v1/campaigns — up to 10,000 recipients (kind defaults to marketing). */
|
|
140
|
+
create: (input, opts = {}) => this.request({ method: "POST", path: "/v1/campaigns", body: input, idempotencyKey: opts.idempotencyKey ?? newKey() }),
|
|
141
|
+
get: (id, opts = {}) => this.request({ method: "GET", path: `/v1/campaigns/${encodeURIComponent(id)}`, query: { include: opts.includeHtml ? "html" : undefined } }),
|
|
142
|
+
};
|
|
143
|
+
domains = {
|
|
144
|
+
list: () => this.request({ method: "GET", path: "/v1/domains" }),
|
|
145
|
+
/** POST /v1/domains (full key). */
|
|
146
|
+
create: (domain) => this.request({ method: "POST", path: "/v1/domains", body: { domain } }),
|
|
147
|
+
get: (id) => this.request({ method: "GET", path: `/v1/domains/${encodeURIComponent(id)}` }),
|
|
148
|
+
delete: (id) => this.request({ method: "DELETE", path: `/v1/domains/${encodeURIComponent(id)}` }),
|
|
149
|
+
};
|
|
150
|
+
webhooks = {
|
|
151
|
+
list: () => this.request({ method: "GET", path: "/v1/webhooks" }),
|
|
152
|
+
/** POST /v1/webhooks (full key). The `signing_secret` is returned only here — store it. */
|
|
153
|
+
create: (input) => this.request({ method: "POST", path: "/v1/webhooks", body: input }),
|
|
154
|
+
get: (id) => this.request({ method: "GET", path: `/v1/webhooks/${encodeURIComponent(id)}` }),
|
|
155
|
+
delete: (id) => this.request({ method: "DELETE", path: `/v1/webhooks/${encodeURIComponent(id)}` }),
|
|
156
|
+
/** POST /v1/webhooks/{id}/test — a signed test event (optionally of a chosen type). */
|
|
157
|
+
test: (id, spec) => this.request({ method: "POST", path: `/v1/webhooks/${encodeURIComponent(id)}/test`, ...(spec ? { body: spec } : {}) }),
|
|
158
|
+
};
|
|
159
|
+
suppressions = {
|
|
160
|
+
list: (params = {}) => this.request({ method: "GET", path: "/v1/suppressions", query: params }),
|
|
161
|
+
add: (input) => this.request({ method: "POST", path: "/v1/suppressions", body: input, idempotent: true }),
|
|
162
|
+
import: (input) => this.request({ method: "POST", path: "/v1/suppressions/import", body: input, idempotent: true }),
|
|
163
|
+
/** DELETE /v1/suppressions/{id or email} (full key). */
|
|
164
|
+
remove: (idOrEmail) => this.request({ method: "DELETE", path: `/v1/suppressions/${encodeURIComponent(idOrEmail)}` }),
|
|
165
|
+
};
|
|
166
|
+
recipients = {
|
|
167
|
+
/**
|
|
168
|
+
* POST /v1/recipients/proof — report the outcome of a message Bul returned to you (`return_ref` from RoutePausedError.returns
|
|
169
|
+
* or from a `withdrawn` event) after you sent it with your own provider. Idempotent by return_ref + outcome.
|
|
170
|
+
*/
|
|
171
|
+
proof: (input) => this.request({ method: "POST", path: "/v1/recipients/proof", body: input, idempotent: true }),
|
|
172
|
+
};
|
|
173
|
+
async *paginate(page) {
|
|
174
|
+
let cursor = null;
|
|
175
|
+
for (;;) {
|
|
176
|
+
const p = await page(cursor);
|
|
177
|
+
for (const item of p.data)
|
|
178
|
+
yield item;
|
|
179
|
+
if (!p.has_more || !p.next_cursor)
|
|
180
|
+
return;
|
|
181
|
+
cursor = p.next_cursor;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export default Bul;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bul API — types. Field names are snake_case, exactly as the API sends and receives them (https://bul.friman.app/docs).
|
|
3
|
+
*/
|
|
4
|
+
export type ApiKeyScope = "read" | "send" | "full";
|
|
5
|
+
export type MessageKind = "transactional" | "marketing";
|
|
6
|
+
export type MessageStatus = "queued" | "sending" | "sent" | "delivered" | "opened" | "clicked" | "bounced" | "complained" | "unsubscribed" | "failed" | "deferred" | "withdrawn";
|
|
7
|
+
export type Tags = Record<string, string>;
|
|
8
|
+
export type Attachment = {
|
|
9
|
+
filename: string;
|
|
10
|
+
content_type: string;
|
|
11
|
+
content: string;
|
|
12
|
+
content_id?: string;
|
|
13
|
+
} | {
|
|
14
|
+
filename: string;
|
|
15
|
+
url: string;
|
|
16
|
+
content_type?: string;
|
|
17
|
+
content_id?: string;
|
|
18
|
+
};
|
|
19
|
+
export interface SendEmailInput {
|
|
20
|
+
from: string;
|
|
21
|
+
to: string[];
|
|
22
|
+
subject: string;
|
|
23
|
+
html?: string;
|
|
24
|
+
text?: string;
|
|
25
|
+
track_clicks?: boolean;
|
|
26
|
+
kind?: MessageKind;
|
|
27
|
+
reply_to?: string[];
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
tags?: Tags;
|
|
30
|
+
attachments?: Attachment[];
|
|
31
|
+
}
|
|
32
|
+
export interface SendEmailResponse {
|
|
33
|
+
id: string;
|
|
34
|
+
recipient: string;
|
|
35
|
+
}
|
|
36
|
+
/** Batch, form 1: the same message to up to 100 recipients. */
|
|
37
|
+
export type BatchObjectInput = SendEmailInput;
|
|
38
|
+
export interface BatchObjectResponse {
|
|
39
|
+
queued: {
|
|
40
|
+
id: string;
|
|
41
|
+
recipient: string;
|
|
42
|
+
}[];
|
|
43
|
+
suppressed: string[];
|
|
44
|
+
invalid: string[];
|
|
45
|
+
}
|
|
46
|
+
/** Batch, form 2: up to 100 different messages, one recipient each. */
|
|
47
|
+
export interface BatchItemInput extends Omit<SendEmailInput, "to"> {
|
|
48
|
+
to: string;
|
|
49
|
+
idempotency_key?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface BatchItemsResponse {
|
|
52
|
+
data: ({
|
|
53
|
+
index: number;
|
|
54
|
+
id: string;
|
|
55
|
+
recipient: string;
|
|
56
|
+
deduplicated?: boolean;
|
|
57
|
+
} | {
|
|
58
|
+
index: number;
|
|
59
|
+
recipient: string;
|
|
60
|
+
error: {
|
|
61
|
+
code: string;
|
|
62
|
+
message: string;
|
|
63
|
+
};
|
|
64
|
+
})[];
|
|
65
|
+
summary: {
|
|
66
|
+
queued: number;
|
|
67
|
+
deduplicated: number;
|
|
68
|
+
failed: number;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export interface BounceInfo {
|
|
72
|
+
event: string;
|
|
73
|
+
bounce_type: "hard" | "soft" | null;
|
|
74
|
+
bounce_sub_type: string | null;
|
|
75
|
+
smtp_status: string | null;
|
|
76
|
+
smtp_diagnostic: string | null;
|
|
77
|
+
timestamp: string;
|
|
78
|
+
}
|
|
79
|
+
export interface Email {
|
|
80
|
+
id: string;
|
|
81
|
+
recipient: string;
|
|
82
|
+
from: string;
|
|
83
|
+
subject: string;
|
|
84
|
+
status: MessageStatus;
|
|
85
|
+
attempts: number;
|
|
86
|
+
campaign_id: string | null;
|
|
87
|
+
last_error: string | null;
|
|
88
|
+
created_at: string;
|
|
89
|
+
reply_to?: string[];
|
|
90
|
+
headers?: Record<string, string>;
|
|
91
|
+
track_clicks?: boolean;
|
|
92
|
+
tags: Tags;
|
|
93
|
+
attachments?: {
|
|
94
|
+
filename: string;
|
|
95
|
+
content_type: string;
|
|
96
|
+
size: number;
|
|
97
|
+
}[];
|
|
98
|
+
content_purged_at?: string | null;
|
|
99
|
+
bounce?: BounceInfo | null;
|
|
100
|
+
html?: string | null;
|
|
101
|
+
text?: string | null;
|
|
102
|
+
}
|
|
103
|
+
export interface ListEmailsParams {
|
|
104
|
+
recipient?: string;
|
|
105
|
+
status?: MessageStatus | MessageStatus[];
|
|
106
|
+
since?: string;
|
|
107
|
+
until?: string;
|
|
108
|
+
campaign_id?: string;
|
|
109
|
+
limit?: number;
|
|
110
|
+
cursor?: string;
|
|
111
|
+
}
|
|
112
|
+
export interface Page<T> {
|
|
113
|
+
data: T[];
|
|
114
|
+
has_more: boolean;
|
|
115
|
+
next_cursor: string | null;
|
|
116
|
+
}
|
|
117
|
+
export interface CampaignInput extends SendEmailInput {
|
|
118
|
+
}
|
|
119
|
+
export interface Campaign {
|
|
120
|
+
id: string;
|
|
121
|
+
status: string;
|
|
122
|
+
[key: string]: unknown;
|
|
123
|
+
}
|
|
124
|
+
export interface Domain {
|
|
125
|
+
id: string;
|
|
126
|
+
domain: string;
|
|
127
|
+
status: "verified" | "pending" | "failed";
|
|
128
|
+
[key: string]: unknown;
|
|
129
|
+
}
|
|
130
|
+
export type WebhookEventName = "delivered" | "opened" | "clicked" | "bounced" | "deferred" | "complained" | "unsubscribed" | "failed" | "withdrawn" | "stuck" | "reputation.warning" | "reputation.paused" | "compliance.unsubscribe_missing";
|
|
131
|
+
export interface Webhook {
|
|
132
|
+
id: string;
|
|
133
|
+
url: string;
|
|
134
|
+
events: WebhookEventName[];
|
|
135
|
+
active: boolean;
|
|
136
|
+
created_at: string;
|
|
137
|
+
}
|
|
138
|
+
export interface CreatedWebhook extends Webhook {
|
|
139
|
+
signing_secret: string;
|
|
140
|
+
}
|
|
141
|
+
export interface WebhookTestSpec {
|
|
142
|
+
event?: WebhookEventName | "test";
|
|
143
|
+
smtp_status?: string;
|
|
144
|
+
smtp_diagnostic?: string;
|
|
145
|
+
bounce_sub_type?: string;
|
|
146
|
+
reason_code?: string;
|
|
147
|
+
attempted?: boolean;
|
|
148
|
+
recipient?: string;
|
|
149
|
+
message_id?: string;
|
|
150
|
+
tags?: Tags;
|
|
151
|
+
}
|
|
152
|
+
export interface WebhookTestResult {
|
|
153
|
+
delivery_id: string;
|
|
154
|
+
event_id: string;
|
|
155
|
+
delivered: boolean;
|
|
156
|
+
response_code: number | null;
|
|
157
|
+
duration_ms: number;
|
|
158
|
+
error: string | null;
|
|
159
|
+
}
|
|
160
|
+
export type SuppressionReason = "hard_bounce" | "complaint" | "manual" | "unsubscribe";
|
|
161
|
+
export interface Suppression {
|
|
162
|
+
id: string;
|
|
163
|
+
recipient: string;
|
|
164
|
+
reason: SuppressionReason;
|
|
165
|
+
source: string | null;
|
|
166
|
+
created_at: string;
|
|
167
|
+
}
|
|
168
|
+
export interface AccountStatus {
|
|
169
|
+
key: {
|
|
170
|
+
name: string | null;
|
|
171
|
+
scope: ApiKeyScope;
|
|
172
|
+
};
|
|
173
|
+
sending: {
|
|
174
|
+
status: "open" | "blocked";
|
|
175
|
+
reason: string | null;
|
|
176
|
+
reputation_warning: boolean;
|
|
177
|
+
frozen?: boolean;
|
|
178
|
+
frozen_at?: string;
|
|
179
|
+
};
|
|
180
|
+
rate_limit: {
|
|
181
|
+
limit: number;
|
|
182
|
+
remaining: number;
|
|
183
|
+
reset_seconds: number;
|
|
184
|
+
};
|
|
185
|
+
domains: {
|
|
186
|
+
domain: string;
|
|
187
|
+
status: "verified" | "pending" | "failed";
|
|
188
|
+
}[];
|
|
189
|
+
queue_processing: boolean;
|
|
190
|
+
compliance: {
|
|
191
|
+
unsubscribe: null | {
|
|
192
|
+
level: "warning" | "strong";
|
|
193
|
+
reason: string;
|
|
194
|
+
detected_at: string;
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export interface Stats {
|
|
199
|
+
sent: number;
|
|
200
|
+
delivered: number;
|
|
201
|
+
bounced: number;
|
|
202
|
+
complained: number;
|
|
203
|
+
opened: number;
|
|
204
|
+
clicked: number;
|
|
205
|
+
unique_opens: number;
|
|
206
|
+
unique_clicks: number;
|
|
207
|
+
/** Estimated human opens: opens not flagged machine_open (Apple MPP, Gmail prefetch, scanners, too-fast opens). */
|
|
208
|
+
human_opens?: number;
|
|
209
|
+
unique_human_opens?: number;
|
|
210
|
+
}
|
|
211
|
+
export interface ProofInput {
|
|
212
|
+
return_ref: string;
|
|
213
|
+
recipient: string;
|
|
214
|
+
provider: string;
|
|
215
|
+
outcome: "delivered" | "bounced" | "complained";
|
|
216
|
+
occurred_at: string;
|
|
217
|
+
provider_message_id?: string;
|
|
218
|
+
}
|
|
219
|
+
/** One outcome per return_ref: bounced/complained supersede delivered (`superseded`); a contradicting report → 409 conflicting_outcome (BulError). */
|
|
220
|
+
export interface ProofResponse {
|
|
221
|
+
accepted: true;
|
|
222
|
+
return_ref: string;
|
|
223
|
+
outcome: ProofInput["outcome"];
|
|
224
|
+
duplicate: boolean;
|
|
225
|
+
superseded?: "delivered";
|
|
226
|
+
}
|
|
227
|
+
export interface FailureReason {
|
|
228
|
+
code: string;
|
|
229
|
+
description: string;
|
|
230
|
+
}
|
|
231
|
+
interface BaseMessageEvent {
|
|
232
|
+
event_id: string;
|
|
233
|
+
message_id: string;
|
|
234
|
+
recipient: string;
|
|
235
|
+
campaign_id: string | null;
|
|
236
|
+
timestamp: string;
|
|
237
|
+
tags: Tags;
|
|
238
|
+
/** Present (true) on events sent by POST /v1/webhooks/{id}/test. */
|
|
239
|
+
test?: true;
|
|
240
|
+
/** GET /v1/events only. */
|
|
241
|
+
id?: string;
|
|
242
|
+
received_at?: string;
|
|
243
|
+
}
|
|
244
|
+
interface BounceFields {
|
|
245
|
+
bounce_type?: "hard" | "soft";
|
|
246
|
+
bounce_sub_type?: string;
|
|
247
|
+
smtp_status?: string;
|
|
248
|
+
smtp_diagnostic?: string;
|
|
249
|
+
}
|
|
250
|
+
export interface DeliveredEvent extends BaseMessageEvent {
|
|
251
|
+
event: "delivered";
|
|
252
|
+
}
|
|
253
|
+
export interface OpenedEvent extends BaseMessageEvent {
|
|
254
|
+
event: "opened";
|
|
255
|
+
/** Estimate: the open looks automatic (image prefetch / scanner), not a person. The event is still a real pixel load. */
|
|
256
|
+
machine_open?: boolean;
|
|
257
|
+
/** Rule, optionally followed by ":<ip owner>" for fast_open / scanner, e.g. "fast_open:netfree", "scanner:security_vendor". */
|
|
258
|
+
machine_open_reason?: "apple_mpp" | "google_prefetch" | "scanner" | "fast_open" | `${"scanner" | "fast_open"}:${string}` | null;
|
|
259
|
+
}
|
|
260
|
+
export interface ClickedEvent extends BaseMessageEvent {
|
|
261
|
+
event: "clicked";
|
|
262
|
+
clicked_url: string;
|
|
263
|
+
}
|
|
264
|
+
export interface BouncedEvent extends BaseMessageEvent, BounceFields {
|
|
265
|
+
event: "bounced";
|
|
266
|
+
}
|
|
267
|
+
export interface DeferredEvent extends BaseMessageEvent, BounceFields {
|
|
268
|
+
event: "deferred";
|
|
269
|
+
}
|
|
270
|
+
export interface ComplainedEvent extends BaseMessageEvent {
|
|
271
|
+
event: "complained";
|
|
272
|
+
}
|
|
273
|
+
export interface UnsubscribedEvent extends BaseMessageEvent {
|
|
274
|
+
event: "unsubscribed";
|
|
275
|
+
}
|
|
276
|
+
export interface FailedEvent extends BaseMessageEvent, BounceFields {
|
|
277
|
+
event: "failed";
|
|
278
|
+
reason: FailureReason;
|
|
279
|
+
/** false only for ambiguous_delivery — the message may have been delivered. */
|
|
280
|
+
safe_to_resend: boolean;
|
|
281
|
+
/** Deprecated text field kept for compatibility. */
|
|
282
|
+
error?: string | null;
|
|
283
|
+
}
|
|
284
|
+
export interface WithdrawnEvent extends BaseMessageEvent {
|
|
285
|
+
event: "withdrawn";
|
|
286
|
+
reason: {
|
|
287
|
+
code: "route_paused" | "recipient_not_proven" | "direct_unavailable" | string;
|
|
288
|
+
description: string;
|
|
289
|
+
};
|
|
290
|
+
attempted: boolean;
|
|
291
|
+
safe_to_resend: true;
|
|
292
|
+
/** Report the result after resending with your provider: POST /v1/recipients/proof. */
|
|
293
|
+
return_ref?: string;
|
|
294
|
+
}
|
|
295
|
+
export interface StuckEvent extends BaseMessageEvent {
|
|
296
|
+
event: "stuck";
|
|
297
|
+
}
|
|
298
|
+
export interface ReputationEvent {
|
|
299
|
+
event: "reputation.warning" | "reputation.paused";
|
|
300
|
+
event_id: string;
|
|
301
|
+
timestamp: string;
|
|
302
|
+
reasons: ("bounce_rate" | "complaint_rate")[];
|
|
303
|
+
sending_status: string;
|
|
304
|
+
window_hours: number;
|
|
305
|
+
volume: number;
|
|
306
|
+
bounced: number;
|
|
307
|
+
complained: number;
|
|
308
|
+
bounce_rate: number;
|
|
309
|
+
complaint_rate: number;
|
|
310
|
+
thresholds: Record<string, number>;
|
|
311
|
+
test?: true;
|
|
312
|
+
}
|
|
313
|
+
export interface ComplianceEvent {
|
|
314
|
+
event: "compliance.unsubscribe_missing";
|
|
315
|
+
event_id: string;
|
|
316
|
+
timestamp: string;
|
|
317
|
+
level: "warning" | "strong";
|
|
318
|
+
reason: string;
|
|
319
|
+
source: string;
|
|
320
|
+
recipients?: number;
|
|
321
|
+
subject?: string;
|
|
322
|
+
test?: true;
|
|
323
|
+
}
|
|
324
|
+
export interface TestEvent {
|
|
325
|
+
event: "test";
|
|
326
|
+
event_id: string;
|
|
327
|
+
timestamp: string;
|
|
328
|
+
test: true;
|
|
329
|
+
message_id: null;
|
|
330
|
+
[key: string]: unknown;
|
|
331
|
+
}
|
|
332
|
+
export type BulEvent = DeliveredEvent | OpenedEvent | ClickedEvent | BouncedEvent | DeferredEvent | ComplainedEvent | UnsubscribedEvent | FailedEvent | WithdrawnEvent | StuckEvent | ReputationEvent | ComplianceEvent | TestEvent;
|
|
333
|
+
export interface ListEventsParams {
|
|
334
|
+
since?: string;
|
|
335
|
+
cursor?: string;
|
|
336
|
+
limit?: number;
|
|
337
|
+
type?: Exclude<WebhookEventName, "stuck" | "reputation.warning" | "reputation.paused" | "compliance.unsubscribe_missing">;
|
|
338
|
+
}
|
|
339
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BulEvent } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Webhook signature: `Bul-Signature` = hex HMAC-SHA256 of `${Bul-Timestamp}.${rawBody}` with the webhook's signing secret.
|
|
4
|
+
* Uses Web Crypto, so it runs on Node 18+, Bun, Deno and edge runtimes. Always verify the **raw** request body (before JSON parsing).
|
|
5
|
+
*/
|
|
6
|
+
type HeaderBag = Headers | Record<string, string | string[] | undefined>;
|
|
7
|
+
export interface VerifyOptions {
|
|
8
|
+
/** Raw body exactly as received (string or bytes). */
|
|
9
|
+
rawBody: string | Uint8Array;
|
|
10
|
+
headers: HeaderBag;
|
|
11
|
+
secret: string;
|
|
12
|
+
/** Reject timestamps older/newer than this many seconds. Default 300 (5 minutes), as recommended in /docs. */
|
|
13
|
+
toleranceSeconds?: number;
|
|
14
|
+
/** For tests. */
|
|
15
|
+
now?: Date;
|
|
16
|
+
}
|
|
17
|
+
/** true if the signature is valid and the timestamp is fresh. */
|
|
18
|
+
export declare function verifyWebhookSignature(opts: VerifyOptions): Promise<boolean>;
|
|
19
|
+
/**
|
|
20
|
+
* Verify and parse a webhook. Throws WebhookVerificationError if the signature is invalid or stale.
|
|
21
|
+
* Deduplicate on `event.event_id` (identical across redeliveries and in GET /v1/events).
|
|
22
|
+
*/
|
|
23
|
+
export declare function constructWebhookEvent(opts: VerifyOptions): Promise<BulEvent>;
|
|
24
|
+
export {};
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { WebhookVerificationError } from "./errors";
|
|
2
|
+
function header(headers, name) {
|
|
3
|
+
if (typeof headers.get === "function")
|
|
4
|
+
return headers.get(name);
|
|
5
|
+
const bag = headers;
|
|
6
|
+
const key = Object.keys(bag).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
7
|
+
const v = key ? bag[key] : undefined;
|
|
8
|
+
return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
|
|
9
|
+
}
|
|
10
|
+
const enc = new TextEncoder();
|
|
11
|
+
async function hmacHex(secret, data) {
|
|
12
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
13
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, data));
|
|
14
|
+
return Array.from(sig, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
15
|
+
}
|
|
16
|
+
function constantTimeEqual(a, b) {
|
|
17
|
+
if (a.length !== b.length)
|
|
18
|
+
return false;
|
|
19
|
+
let diff = 0;
|
|
20
|
+
for (let i = 0; i < a.length; i++)
|
|
21
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
22
|
+
return diff === 0;
|
|
23
|
+
}
|
|
24
|
+
/** true if the signature is valid and the timestamp is fresh. */
|
|
25
|
+
export async function verifyWebhookSignature(opts) {
|
|
26
|
+
const ts = header(opts.headers, "bul-timestamp");
|
|
27
|
+
const got = header(opts.headers, "bul-signature");
|
|
28
|
+
if (!ts || !got || !/^\d+$/.test(ts))
|
|
29
|
+
return false;
|
|
30
|
+
const now = (opts.now ?? new Date()).getTime() / 1000;
|
|
31
|
+
if (Math.abs(now - Number(ts)) > (opts.toleranceSeconds ?? 300))
|
|
32
|
+
return false;
|
|
33
|
+
const body = typeof opts.rawBody === "string" ? enc.encode(opts.rawBody) : opts.rawBody;
|
|
34
|
+
const prefix = enc.encode(`${ts}.`);
|
|
35
|
+
const data = new Uint8Array(new ArrayBuffer(prefix.length + body.length));
|
|
36
|
+
data.set(prefix, 0);
|
|
37
|
+
data.set(body, prefix.length);
|
|
38
|
+
const expected = await hmacHex(opts.secret, data);
|
|
39
|
+
return constantTimeEqual(got.trim().toLowerCase(), expected);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Verify and parse a webhook. Throws WebhookVerificationError if the signature is invalid or stale.
|
|
43
|
+
* Deduplicate on `event.event_id` (identical across redeliveries and in GET /v1/events).
|
|
44
|
+
*/
|
|
45
|
+
export async function constructWebhookEvent(opts) {
|
|
46
|
+
if (!(await verifyWebhookSignature(opts)))
|
|
47
|
+
throw new WebhookVerificationError("Invalid or expired Bul webhook signature");
|
|
48
|
+
const text = typeof opts.rawBody === "string" ? opts.rawBody : new TextDecoder().decode(opts.rawBody);
|
|
49
|
+
return JSON.parse(text);
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bul-email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Official JavaScript/TypeScript client for the Bul email API (bul.friman.app)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"email",
|
|
7
|
+
"email-api",
|
|
8
|
+
"transactional-email",
|
|
9
|
+
"bul",
|
|
10
|
+
"hebrew",
|
|
11
|
+
"israel",
|
|
12
|
+
"resend-alternative"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://bul.friman.app/docs#sdk",
|
|
15
|
+
"author": "BUL <support@friman.app>",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"email": "support@friman.app"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
39
|
+
"test": "bun test",
|
|
40
|
+
"prepublishOnly": "tsc -p tsconfig.json"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|