@the-smithy/bellow 0.0.0-stage → 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/CHANGELOG.md +17 -0
- package/LICENSE +6 -0
- package/README.md +174 -2
- package/dist/chunk-IO7QZUKG.js +101 -0
- package/dist/client-BgpTyZ0a.d.ts +167 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +201 -0
- package/dist/testing.d.ts +37 -0
- package/dist/testing.js +62 -0
- package/llms.md +74 -0
- package/package.json +50 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @the-smithy/bellow
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `Bellow` client, with `createBellow()` and `createBellowFromEnv()` (`BELLOW_API_KEY`, `EMAIL_FROM`,
|
|
8
|
+
`EMAIL_REPLY_TO`, `BELLOW_URL`).
|
|
9
|
+
- `send()` returns a result and never throws for send problems. It validates locally (addresses, header
|
|
10
|
+
injection, size, tags, required `category`), uses an automatic or caller-supplied idempotency key (unsafe
|
|
11
|
+
keys are hashed), and retries network errors, timeouts, 429 and 5xx with backoff. `sendOrThrow()` is the
|
|
12
|
+
throwing variant.
|
|
13
|
+
- `getMessage()` returns a message's status and delivery events.
|
|
14
|
+
- Typed `BellowError` with `code`, `status`, `details`, `retryAfter` and `retryable`.
|
|
15
|
+
- Security: refuses to run in a browser, requires HTTPS, never follows redirects, keeps the key out of
|
|
16
|
+
logs and serialized output.
|
|
17
|
+
- `@the-smithy/bellow/testing`: `createMockBellow()` for app tests.
|
package/LICENSE
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,175 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @the-smithy/bellow
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The standard way for The Smithy's apps to send transactional email through
|
|
4
|
+
[Bellow](https://bellow.thesmithy.io). Every Smithy site uses this package rather than calling an email
|
|
5
|
+
provider directly, so every email is validated, labelled, safe to retry, and sent from a verified domain.
|
|
6
|
+
|
|
7
|
+
- **Server-side only.** It refuses to run in a browser: the API key can send email as your domains.
|
|
8
|
+
- **Validated before it leaves your server.** Bad addresses, header injection, missing bodies, oversize
|
|
9
|
+
emails and malformed tags fail locally with a clear message.
|
|
10
|
+
- **Safe to retry.** Every send carries an idempotency key (yours, or one the SDK makes), and network
|
|
11
|
+
errors, timeouts, 429s and 5xx responses are retried with backoff.
|
|
12
|
+
- **Never throws for a send problem.** `send` returns `{ ok: false, error }`, so a mail hiccup can't break
|
|
13
|
+
a checkout or a sign-up.
|
|
14
|
+
|
|
15
|
+
Agents: read [llms.md](./llms.md) for the condensed reference.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Public on npm: no registry config or token needed.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm add @the-smithy/bellow
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It only sends with a Bellow API key, which Smithy staff issue per client and environment.
|
|
26
|
+
|
|
27
|
+
Requires Node 20.3+ (or any runtime with `fetch`, `AbortSignal.any` and Web Crypto).
|
|
28
|
+
|
|
29
|
+
## Configure
|
|
30
|
+
|
|
31
|
+
Smithy staff create the client, verify its domain and issue an API key in the Bellow dashboard. Set these
|
|
32
|
+
server-side environment variables (in `.env.local` and on the host):
|
|
33
|
+
|
|
34
|
+
| Variable | Required | Example |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `BELLOW_API_KEY` | yes (secret) | `bk_…` |
|
|
37
|
+
| `EMAIL_FROM` | yes | `Acme <hello@acme.com>`: a domain verified for this client in Bellow |
|
|
38
|
+
| `EMAIL_REPLY_TO` | no | `support@acme.com` |
|
|
39
|
+
| `BELLOW_URL` | no | Only to point at a non-production Bellow |
|
|
40
|
+
|
|
41
|
+
Never put the key in a `NEXT_PUBLIC_*` variable, client code, the repo or logs. Use one key per app and
|
|
42
|
+
environment, so any one can be revoked alone.
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
Create one client for the app, in a server-only module:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// lib/email.ts
|
|
50
|
+
import "server-only"; // Next.js
|
|
51
|
+
import { createBellowFromEnv } from "@the-smithy/bellow";
|
|
52
|
+
|
|
53
|
+
/** null when BELLOW_API_KEY or EMAIL_FROM isn't set: treat email as off and fall back. */
|
|
54
|
+
export const bellow = createBellowFromEnv();
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Send:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { bellow } from "@/lib/email";
|
|
61
|
+
|
|
62
|
+
const result = await bellow?.send(
|
|
63
|
+
{
|
|
64
|
+
to: user.email,
|
|
65
|
+
subject: "Reset your password",
|
|
66
|
+
html,
|
|
67
|
+
text,
|
|
68
|
+
category: "password-reset",
|
|
69
|
+
},
|
|
70
|
+
{ idempotencyKey: `password-reset-${user.id}-${Math.floor(Date.now() / 600_000)}` },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!result) {
|
|
74
|
+
// Email isn't configured here: show the link on screen instead.
|
|
75
|
+
} else if (!result.ok) {
|
|
76
|
+
console.error("[email]", result.error.code, result.error.message);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### The email
|
|
81
|
+
|
|
82
|
+
| Field | Notes |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `to` | Address or array. `ada@x.com` or `Ada Lovelace <ada@x.com>`. |
|
|
85
|
+
| `cc`, `bcc` | Optional. 50 recipients at most across to, cc and bcc, with no address twice. |
|
|
86
|
+
| `from` | Optional; defaults to `EMAIL_FROM`. Must be on a domain verified for your client. |
|
|
87
|
+
| `replyTo` | Optional, up to 5; defaults to `EMAIL_REPLY_TO`. |
|
|
88
|
+
| `subject` | Required, one line. |
|
|
89
|
+
| `html`, `text` | At least one; send both. 512 KB in total with the subject. |
|
|
90
|
+
| `category` | **Required.** What kind of email this is: `password-reset`, `receipt`, `form-alert`. Letters, numbers, `_`, `-`. |
|
|
91
|
+
| `tags` | Optional extra labels, up to 9. Same character rules; names can't start with `bellow`. |
|
|
92
|
+
|
|
93
|
+
### Options
|
|
94
|
+
|
|
95
|
+
- `idempotencyKey`: same key and same email → sent at most once, however often you call. Build it from
|
|
96
|
+
stable ids (`order-1042-receipt`), not from the time of the retry. Any string works: keys with characters
|
|
97
|
+
Bellow doesn't accept (an email address, say) are hashed into a stable safe key. Reusing a key for a
|
|
98
|
+
*different* email is a `conflict` error.
|
|
99
|
+
- `retries`: override the client's retry count for one call.
|
|
100
|
+
- `signal`: an `AbortSignal` to cancel.
|
|
101
|
+
|
|
102
|
+
### The result
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
type SendResult =
|
|
106
|
+
| { ok: true; id: string; status: string; suppressed: string[]; duplicate: boolean }
|
|
107
|
+
| { ok: false; error: BellowError };
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- `suppressed`: recipients Bellow skipped because they bounced or complained before. The rest were sent.
|
|
111
|
+
- `duplicate`: this idempotency key was already used for this email, so nothing new was sent.
|
|
112
|
+
- `sendOrThrow()` is the same, but throws the `BellowError`.
|
|
113
|
+
|
|
114
|
+
### Errors
|
|
115
|
+
|
|
116
|
+
`BellowError` has `code`, `message` (safe to log; never contains the key), `status`, `details`,
|
|
117
|
+
`retryAfter` and `retryable`. `isBellowError(value)` narrows.
|
|
118
|
+
|
|
119
|
+
| `code` | Meaning | Retried by the SDK |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| `invalid_email` | Failed validation locally; `details` lists each problem. No request was made. | no |
|
|
122
|
+
| `bad_request` | The API rejected the body; `details` names the field. | no |
|
|
123
|
+
| `unauthorized` | Missing, wrong or revoked key. | no |
|
|
124
|
+
| `forbidden` | The from domain isn't verified for your client, or the client is paused. | no |
|
|
125
|
+
| `conflict` | Idempotency key reused for a different email. | no |
|
|
126
|
+
| `unprocessable` | Every To address is suppressed; nothing was sent. | no |
|
|
127
|
+
| `rate_limited` | Over your client's per-minute or daily limit, or SES is busy. | yes, if `Retry-After` ≤ `maxRetryAfter` |
|
|
128
|
+
| `unavailable`, `server_error` | SES or Bellow had a problem. | yes |
|
|
129
|
+
| `network_error`, `timeout` | Couldn't reach Bellow in time. | yes |
|
|
130
|
+
| `aborted` | Your `signal` fired. | no |
|
|
131
|
+
|
|
132
|
+
### Delivery status
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const status = await bellow.getMessage(result.id);
|
|
136
|
+
if (status.ok) console.log(status.message.status, status.message.events);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`status` moves from `sent` to `delivered`, or to `bounced`, `complained`, `rejected` or `failed`. A key can
|
|
140
|
+
only read its own client's messages.
|
|
141
|
+
|
|
142
|
+
### Client options
|
|
143
|
+
|
|
144
|
+
`new Bellow({ apiKey, from?, replyTo?, baseUrl?, timeout? = 15000, retries? = 2, maxRetryAfter? = 30, fetch? })`.
|
|
145
|
+
`createBellowFromEnv(env?, overrides?)` reads the variables above and accepts the same options as overrides.
|
|
146
|
+
`validateEmail(email)` returns the list of problems without sending, which is handy in forms and tests.
|
|
147
|
+
|
|
148
|
+
## Testing your app
|
|
149
|
+
|
|
150
|
+
Depend on the `BellowSender` interface, and use the mock from `@the-smithy/bellow/testing` in tests:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { createMockBellow } from "@the-smithy/bellow/testing";
|
|
154
|
+
|
|
155
|
+
const mock = createMockBellow({ from: "Acme <hello@acme.com>" });
|
|
156
|
+
await sendWelcome(mock.client, user);
|
|
157
|
+
expect(mock.sent).toHaveLength(1);
|
|
158
|
+
expect(mock.sent[0].email.category).toBe("welcome");
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The mock validates exactly like the real client, dedupes idempotency keys, and can fail on demand
|
|
162
|
+
(`fail: (email) => "rate_limited"`) or report addresses as suppressed (`suppressed: ["gone@x.com"]`).
|
|
163
|
+
|
|
164
|
+
## Security rules for apps
|
|
165
|
+
|
|
166
|
+
- Server-side only; one key per app and environment; the key only in secret environment variables.
|
|
167
|
+
- **Never let a visitor choose both the recipient and the content.** Public forms must send to a fixed
|
|
168
|
+
address (the site owner), or a fixed template to the visitor's own address, and must be rate-limited or
|
|
169
|
+
CAPTCHA-protected. If a form is abused, Bellow pauses the whole client.
|
|
170
|
+
- Don't put email bodies or the key in logs. Log `error.code` and `error.message`.
|
|
171
|
+
|
|
172
|
+
## While Bellow is in the SES sandbox
|
|
173
|
+
|
|
174
|
+
Until Amazon approves production access, SES only delivers to addresses verified in SES. Sends to anyone
|
|
175
|
+
else fail with `unavailable` (MessageRejected). Ask Smithy staff which address to test with.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var RETRYABLE = /* @__PURE__ */ new Set(["rate_limited", "unavailable", "server_error", "network_error", "timeout"]);
|
|
3
|
+
var BellowError = class extends Error {
|
|
4
|
+
name = "BellowError";
|
|
5
|
+
code;
|
|
6
|
+
/** HTTP status, when the API answered. */
|
|
7
|
+
status;
|
|
8
|
+
/** Field-level problems for `invalid_email` and `bad_request`; suppressed addresses for `unprocessable`. */
|
|
9
|
+
details;
|
|
10
|
+
/** Seconds the API asked us to wait (429). */
|
|
11
|
+
retryAfter;
|
|
12
|
+
constructor(code, message, extra = {}) {
|
|
13
|
+
super(message, extra.cause === void 0 ? void 0 : { cause: extra.cause });
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.status = extra.status;
|
|
16
|
+
this.details = extra.details;
|
|
17
|
+
this.retryAfter = extra.retryAfter;
|
|
18
|
+
}
|
|
19
|
+
/** True when sending again later (with the same idempotency key) may succeed. */
|
|
20
|
+
get retryable() {
|
|
21
|
+
return RETRYABLE.has(this.code);
|
|
22
|
+
}
|
|
23
|
+
toJSON() {
|
|
24
|
+
return { name: this.name, code: this.code, message: this.message, status: this.status, details: this.details, retryAfter: this.retryAfter };
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var isBellowError = (value) => value instanceof BellowError;
|
|
28
|
+
|
|
29
|
+
// src/validate.ts
|
|
30
|
+
var LIMITS = {
|
|
31
|
+
maxRecipients: 50,
|
|
32
|
+
maxReplyTo: 5,
|
|
33
|
+
maxTags: 10,
|
|
34
|
+
/** subject + html + text, in UTF-8 bytes. */
|
|
35
|
+
maxContentBytes: 512 * 1024,
|
|
36
|
+
maxSubjectLength: 998
|
|
37
|
+
};
|
|
38
|
+
var LOCAL = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}$/;
|
|
39
|
+
var DOMAIN = /^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/;
|
|
40
|
+
var TAG = /^[A-Za-z0-9_-]{1,128}$/;
|
|
41
|
+
function parseAddress(input) {
|
|
42
|
+
if (typeof input !== "string" || /[\r\n\0]/.test(input)) return null;
|
|
43
|
+
const trimmed = input.trim();
|
|
44
|
+
const named = trimmed.match(/^(?:"([^"\\]*)"|([^"<>@,;]*?))\s*<([^<>\s]+)>$/);
|
|
45
|
+
if (named && (named[1] ?? named[2] ?? "").trim().length > 120) return null;
|
|
46
|
+
const value = named ? named[3] : trimmed;
|
|
47
|
+
const at = value.lastIndexOf("@");
|
|
48
|
+
if (at < 1 || value.length > 254) return null;
|
|
49
|
+
const local = value.slice(0, at);
|
|
50
|
+
const domain = value.slice(at + 1);
|
|
51
|
+
if (!LOCAL.test(local) || local.startsWith(".") || local.endsWith(".") || local.includes("..")) return null;
|
|
52
|
+
if (!DOMAIN.test(domain)) return null;
|
|
53
|
+
return `${local}@${domain}`.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
var isValidTag = (value) => typeof value === "string" && TAG.test(value);
|
|
56
|
+
var list = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
57
|
+
function validateEmail(email) {
|
|
58
|
+
const issues = [];
|
|
59
|
+
const add = (field, message) => issues.push({ field, message });
|
|
60
|
+
if (!email.from) add("from", "Set `from`, or a default `from` on the client (EMAIL_FROM).");
|
|
61
|
+
else if (!parseAddress(email.from)) add("from", "`from` must look like `hello@yourdomain.com` or `Name <hello@yourdomain.com>`.");
|
|
62
|
+
const seen = /* @__PURE__ */ new Set();
|
|
63
|
+
let recipients = 0;
|
|
64
|
+
for (const field of ["to", "cc", "bcc"]) {
|
|
65
|
+
for (const value of list(email[field])) {
|
|
66
|
+
recipients++;
|
|
67
|
+
const parsed = parseAddress(value);
|
|
68
|
+
if (!parsed) add(field, `Not a usable address: ${JSON.stringify(String(value).slice(0, 120))}. Use one address per entry.`);
|
|
69
|
+
else if (seen.has(parsed)) add(field, `${parsed} appears more than once across to, cc and bcc.`);
|
|
70
|
+
else seen.add(parsed);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (list(email.to).length === 0) add("to", "Add at least one `to` address.");
|
|
74
|
+
if (recipients > LIMITS.maxRecipients) add("to", `Up to ${LIMITS.maxRecipients} recipients in total (to, cc and bcc).`);
|
|
75
|
+
const replyTo = list(email.replyTo);
|
|
76
|
+
if (replyTo.length > LIMITS.maxReplyTo) add("replyTo", `Up to ${LIMITS.maxReplyTo} replyTo addresses.`);
|
|
77
|
+
for (const value of replyTo) if (!parseAddress(value)) add("replyTo", `Not a usable address: ${JSON.stringify(String(value).slice(0, 120))}.`);
|
|
78
|
+
if (typeof email.subject !== "string" || !email.subject.trim()) add("subject", "Add a subject.");
|
|
79
|
+
else if (/[\r\n]/.test(email.subject)) add("subject", "The subject can't contain line breaks.");
|
|
80
|
+
else if (email.subject.length > LIMITS.maxSubjectLength) add("subject", `The subject is over ${LIMITS.maxSubjectLength} characters.`);
|
|
81
|
+
if (!email.html && !email.text) add("html", "Send `html`, `text`, or both (both is best for deliverability).");
|
|
82
|
+
const bytes = new TextEncoder().encode(`${email.subject ?? ""}${email.html ?? ""}${email.text ?? ""}`).length;
|
|
83
|
+
if (bytes > LIMITS.maxContentBytes) add("html", `The email is too large (limit ${LIMITS.maxContentBytes / 1024} KB for subject, html and text together).`);
|
|
84
|
+
if (!isValidTag(email.category)) add("category", "`category` is required: letters, numbers, _ and - only, e.g. `password-reset`.");
|
|
85
|
+
const tags = Object.entries(email.tags ?? {});
|
|
86
|
+
if (tags.length + 1 > LIMITS.maxTags) add("tags", `Up to ${LIMITS.maxTags - 1} tags besides category.`);
|
|
87
|
+
for (const [name, value] of tags) {
|
|
88
|
+
if (!isValidTag(name) || !isValidTag(value)) add("tags", `Tag ${JSON.stringify(name)}: names and values use letters, numbers, _ and - only (up to 128).`);
|
|
89
|
+
if (name === "category") add("tags", "Use the `category` field rather than a `category` tag.");
|
|
90
|
+
if (name.startsWith("bellow")) add("tags", `Tag names starting with "bellow" are reserved (${name}).`);
|
|
91
|
+
}
|
|
92
|
+
return issues;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
BellowError,
|
|
97
|
+
isBellowError,
|
|
98
|
+
LIMITS,
|
|
99
|
+
parseAddress,
|
|
100
|
+
validateEmail
|
|
101
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** Codes from the Bellow API, plus the ones the SDK raises itself before or around a request. */
|
|
2
|
+
type BellowErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "conflict" | "unprocessable" | "rate_limited" | "unavailable" | "server_error" | "invalid_email" | "invalid_request" | "network_error" | "timeout" | "aborted";
|
|
3
|
+
/**
|
|
4
|
+
* Every failure the SDK reports. Its message is safe to log: it never contains the API key.
|
|
5
|
+
* Show `message` to staff and developers, not to site visitors.
|
|
6
|
+
*/
|
|
7
|
+
declare class BellowError extends Error {
|
|
8
|
+
readonly name = "BellowError";
|
|
9
|
+
readonly code: BellowErrorCode;
|
|
10
|
+
/** HTTP status, when the API answered. */
|
|
11
|
+
readonly status?: number;
|
|
12
|
+
/** Field-level problems for `invalid_email` and `bad_request`; suppressed addresses for `unprocessable`. */
|
|
13
|
+
readonly details?: unknown;
|
|
14
|
+
/** Seconds the API asked us to wait (429). */
|
|
15
|
+
readonly retryAfter?: number;
|
|
16
|
+
constructor(code: BellowErrorCode, message: string, extra?: {
|
|
17
|
+
status?: number;
|
|
18
|
+
details?: unknown;
|
|
19
|
+
retryAfter?: number;
|
|
20
|
+
cause?: unknown;
|
|
21
|
+
});
|
|
22
|
+
/** True when sending again later (with the same idempotency key) may succeed. */
|
|
23
|
+
get retryable(): boolean;
|
|
24
|
+
toJSON(): {
|
|
25
|
+
name: string;
|
|
26
|
+
code: BellowErrorCode;
|
|
27
|
+
message: string;
|
|
28
|
+
status: number | undefined;
|
|
29
|
+
details: unknown;
|
|
30
|
+
retryAfter: number | undefined;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
declare const isBellowError: (value: unknown) => value is BellowError;
|
|
34
|
+
|
|
35
|
+
declare const DEFAULT_BASE_URL = "https://bellow.thesmithy.io";
|
|
36
|
+
/** Stamped from package.json at build time ("dev" when running from source). */
|
|
37
|
+
declare const SDK_VERSION: string;
|
|
38
|
+
/** One transactional email. */
|
|
39
|
+
type Email = {
|
|
40
|
+
/** `hello@yourdomain.com` or `Name <hello@yourdomain.com>`, on a domain verified for your client in Bellow. Defaults to the client's `from`. */
|
|
41
|
+
from?: string;
|
|
42
|
+
to: string | string[];
|
|
43
|
+
cc?: string | string[];
|
|
44
|
+
bcc?: string | string[];
|
|
45
|
+
/** Defaults to the client's `replyTo`. Up to 5. */
|
|
46
|
+
replyTo?: string | string[];
|
|
47
|
+
/** One line. */
|
|
48
|
+
subject: string;
|
|
49
|
+
/** Send both html and text when you can. */
|
|
50
|
+
html?: string;
|
|
51
|
+
text?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Required: what kind of email this is, e.g. `password-reset`, `receipt`, `form-alert`.
|
|
54
|
+
* Letters, numbers, _ and -. It's how messages are grouped and found in the Bellow dashboard.
|
|
55
|
+
*/
|
|
56
|
+
category: string;
|
|
57
|
+
/** Extra labels, up to 9: letters, numbers, _ and - for names and values. */
|
|
58
|
+
tags?: Record<string, string>;
|
|
59
|
+
};
|
|
60
|
+
type SendOptions = {
|
|
61
|
+
/**
|
|
62
|
+
* Same key + same email → Bellow sends at most once, however often you call. Build it from stable
|
|
63
|
+
* ids (`order-1042-receipt`). Any string works; unsafe characters are hashed. Without one, the SDK
|
|
64
|
+
* makes a key per call, so its own retries are still safe.
|
|
65
|
+
*/
|
|
66
|
+
idempotencyKey?: string;
|
|
67
|
+
/** Override the client's retry count for this call. */
|
|
68
|
+
retries?: number;
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
};
|
|
71
|
+
type SendResult = {
|
|
72
|
+
ok: true;
|
|
73
|
+
id: string;
|
|
74
|
+
status: string;
|
|
75
|
+
suppressed: string[];
|
|
76
|
+
duplicate: boolean;
|
|
77
|
+
} | {
|
|
78
|
+
ok: false;
|
|
79
|
+
error: BellowError;
|
|
80
|
+
};
|
|
81
|
+
type MessageEvent = {
|
|
82
|
+
type: string;
|
|
83
|
+
recipient: string | null;
|
|
84
|
+
subtype: string | null;
|
|
85
|
+
at: string;
|
|
86
|
+
};
|
|
87
|
+
type Message = {
|
|
88
|
+
id: string;
|
|
89
|
+
status: "queued" | "sent" | "delivered" | "delivery_delayed" | "bounced" | "complained" | "rejected" | "failed";
|
|
90
|
+
from: string;
|
|
91
|
+
to: string[];
|
|
92
|
+
cc: string[];
|
|
93
|
+
bcc: string[];
|
|
94
|
+
subject: string;
|
|
95
|
+
tags: Record<string, string>;
|
|
96
|
+
createdAt: string;
|
|
97
|
+
events: MessageEvent[];
|
|
98
|
+
};
|
|
99
|
+
type MessageResult = {
|
|
100
|
+
ok: true;
|
|
101
|
+
message: Message;
|
|
102
|
+
} | {
|
|
103
|
+
ok: false;
|
|
104
|
+
error: BellowError;
|
|
105
|
+
};
|
|
106
|
+
type BellowOptions = {
|
|
107
|
+
/** `bk_…`, from the Bellow dashboard. Server-side only. */
|
|
108
|
+
apiKey: string;
|
|
109
|
+
/** Default sender for every email (usually EMAIL_FROM). */
|
|
110
|
+
from?: string;
|
|
111
|
+
/** Default reply-to for every email (usually EMAIL_REPLY_TO). */
|
|
112
|
+
replyTo?: string;
|
|
113
|
+
/** Defaults to https://bellow.thesmithy.io. Must be HTTPS (http://localhost is allowed for development). */
|
|
114
|
+
baseUrl?: string;
|
|
115
|
+
/** Per attempt, in milliseconds. Default 15000. */
|
|
116
|
+
timeout?: number;
|
|
117
|
+
/** Retries for network errors, timeouts, 429 and 5xx. Default 2. */
|
|
118
|
+
retries?: number;
|
|
119
|
+
/** Longest Retry-After (seconds) the SDK will wait for before giving up. Default 30. */
|
|
120
|
+
maxRetryAfter?: number;
|
|
121
|
+
fetch?: typeof fetch;
|
|
122
|
+
/**
|
|
123
|
+
* The SDK refuses to run in a browser, because the key can send email as your domains.
|
|
124
|
+
* Only set this for a trusted, non-public environment (e.g. an Electron main process).
|
|
125
|
+
*/
|
|
126
|
+
dangerouslyAllowBrowser?: boolean;
|
|
127
|
+
};
|
|
128
|
+
/** What both the real client and the testing mock implement. Depend on this in your own code. */
|
|
129
|
+
interface BellowSender {
|
|
130
|
+
send(email: Email, options?: SendOptions): Promise<SendResult>;
|
|
131
|
+
getMessage(id: string, options?: {
|
|
132
|
+
signal?: AbortSignal;
|
|
133
|
+
}): Promise<MessageResult>;
|
|
134
|
+
}
|
|
135
|
+
declare class Bellow implements BellowSender {
|
|
136
|
+
#private;
|
|
137
|
+
constructor(options: BellowOptions);
|
|
138
|
+
/** Where this client sends. Useful in logs; never includes the key. */
|
|
139
|
+
get baseUrl(): string;
|
|
140
|
+
/**
|
|
141
|
+
* Sends one email. Never throws for an API or network problem: check `result.ok`. Invalid input is
|
|
142
|
+
* reported as `invalid_email` without calling the API.
|
|
143
|
+
*/
|
|
144
|
+
send(email: Email, options?: SendOptions): Promise<SendResult>;
|
|
145
|
+
/** Like `send`, but throws a BellowError instead of returning `{ ok: false }`. */
|
|
146
|
+
sendOrThrow(email: Email, options?: SendOptions): Promise<{
|
|
147
|
+
ok: true;
|
|
148
|
+
id: string;
|
|
149
|
+
status: string;
|
|
150
|
+
suppressed: string[];
|
|
151
|
+
duplicate: boolean;
|
|
152
|
+
}>;
|
|
153
|
+
/** A message sent with this client's key, with its delivery events. Messages of other clients are `not_found`. */
|
|
154
|
+
getMessage(id: string, options?: {
|
|
155
|
+
signal?: AbortSignal;
|
|
156
|
+
}): Promise<MessageResult>;
|
|
157
|
+
}
|
|
158
|
+
declare function createBellow(options: BellowOptions): Bellow;
|
|
159
|
+
type Env = Record<string, string | undefined>;
|
|
160
|
+
/**
|
|
161
|
+
* The standard setup: BELLOW_API_KEY, EMAIL_FROM, and optionally EMAIL_REPLY_TO and BELLOW_URL.
|
|
162
|
+
* Returns null when BELLOW_API_KEY or EMAIL_FROM is missing, so the app can treat email as off
|
|
163
|
+
* (skip and fall back) instead of crashing in environments without email.
|
|
164
|
+
*/
|
|
165
|
+
declare function createBellowFromEnv(env?: Env, overrides?: Partial<BellowOptions>): Bellow | null;
|
|
166
|
+
|
|
167
|
+
export { Bellow as B, DEFAULT_BASE_URL as D, type Email as E, type Message as M, SDK_VERSION as S, BellowError as a, type BellowErrorCode as b, type BellowOptions as c, type BellowSender as d, type MessageEvent as e, type MessageResult as f, type SendOptions as g, type SendResult as h, createBellow as i, createBellowFromEnv as j, isBellowError as k };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export { B as Bellow, a as BellowError, b as BellowErrorCode, c as BellowOptions, d as BellowSender, D as DEFAULT_BASE_URL, E as Email, M as Message, e as MessageEvent, f as MessageResult, S as SDK_VERSION, g as SendOptions, h as SendResult, i as createBellow, j as createBellowFromEnv, k as isBellowError } from './client-BgpTyZ0a.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A key Bellow will accept, from any string. Safe keys pass through unchanged so they're easy to
|
|
5
|
+
* find in the dashboard; anything else (an email address, spaces, a long key) is hashed, so the
|
|
6
|
+
* same input always maps to the same key.
|
|
7
|
+
*/
|
|
8
|
+
declare function toIdempotencyKey(key: string): Promise<string>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Client-side checks that mirror Bellow's server rules (src/lib/send in the Bellow app), so a bad
|
|
12
|
+
* email fails fast with a clear message instead of a round trip and a 400. The server re-checks
|
|
13
|
+
* everything; these exist for developer experience and to stop header injection at the source.
|
|
14
|
+
*/
|
|
15
|
+
declare const LIMITS: {
|
|
16
|
+
readonly maxRecipients: 50;
|
|
17
|
+
readonly maxReplyTo: 5;
|
|
18
|
+
readonly maxTags: 10;
|
|
19
|
+
/** subject + html + text, in UTF-8 bytes. */
|
|
20
|
+
readonly maxContentBytes: number;
|
|
21
|
+
readonly maxSubjectLength: 998;
|
|
22
|
+
};
|
|
23
|
+
/** The bare, lowercased address in `email` or `Name <email>`, or null if it isn't one. */
|
|
24
|
+
declare function parseAddress(input: string): string | null;
|
|
25
|
+
type Issue = {
|
|
26
|
+
field: string;
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Every problem with an email, or an empty list. `email.from` must already be resolved (the
|
|
31
|
+
* client fills it from its default).
|
|
32
|
+
*/
|
|
33
|
+
declare function validateEmail(email: {
|
|
34
|
+
from?: string;
|
|
35
|
+
to: string | string[];
|
|
36
|
+
cc?: string | string[];
|
|
37
|
+
bcc?: string | string[];
|
|
38
|
+
replyTo?: string | string[];
|
|
39
|
+
subject: string;
|
|
40
|
+
html?: string;
|
|
41
|
+
text?: string;
|
|
42
|
+
category: string;
|
|
43
|
+
tags?: Record<string, string>;
|
|
44
|
+
}): Issue[];
|
|
45
|
+
|
|
46
|
+
export { type Issue, LIMITS, parseAddress, toIdempotencyKey, validateEmail };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BellowError,
|
|
3
|
+
LIMITS,
|
|
4
|
+
isBellowError,
|
|
5
|
+
parseAddress,
|
|
6
|
+
validateEmail
|
|
7
|
+
} from "./chunk-IO7QZUKG.js";
|
|
8
|
+
|
|
9
|
+
// src/idempotency.ts
|
|
10
|
+
var SAFE = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
11
|
+
async function sha256Hex(value) {
|
|
12
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
13
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
14
|
+
}
|
|
15
|
+
async function toIdempotencyKey(key) {
|
|
16
|
+
return SAFE.test(key) ? key : `h-${await sha256Hex(key)}`;
|
|
17
|
+
}
|
|
18
|
+
var randomIdempotencyKey = () => `auto-${globalThis.crypto.randomUUID()}`;
|
|
19
|
+
|
|
20
|
+
// src/client.ts
|
|
21
|
+
var DEFAULT_BASE_URL = "https://bellow.thesmithy.io";
|
|
22
|
+
var SDK_VERSION = true ? "0.1.0" : "dev";
|
|
23
|
+
var KEY = /^bk_[A-Za-z0-9_-]{43}$/;
|
|
24
|
+
var sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
25
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
26
|
+
const timer = setTimeout(resolve, ms);
|
|
27
|
+
signal?.addEventListener("abort", () => (clearTimeout(timer), reject(signal.reason)), { once: true });
|
|
28
|
+
});
|
|
29
|
+
var list = (v) => v === void 0 ? void 0 : Array.isArray(v) ? v : [v];
|
|
30
|
+
function checkBaseUrl(value) {
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(value);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`Bellow: baseUrl ${JSON.stringify(value)} isn't a URL.`);
|
|
36
|
+
}
|
|
37
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
38
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
|
|
39
|
+
throw new Error("Bellow: baseUrl must use HTTPS, so the API key is never sent in the clear.");
|
|
40
|
+
}
|
|
41
|
+
return url.origin;
|
|
42
|
+
}
|
|
43
|
+
var Bellow = class {
|
|
44
|
+
// Private fields don't show up in console.log, JSON.stringify or error reports.
|
|
45
|
+
#apiKey;
|
|
46
|
+
#fetch;
|
|
47
|
+
#from;
|
|
48
|
+
#replyTo;
|
|
49
|
+
#baseUrl;
|
|
50
|
+
#timeout;
|
|
51
|
+
#retries;
|
|
52
|
+
#maxRetryAfter;
|
|
53
|
+
constructor(options) {
|
|
54
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
55
|
+
if (isBrowser && !options.dangerouslyAllowBrowser) {
|
|
56
|
+
throw new Error("Bellow: refusing to run in a browser. The API key can send email as your domains; call Bellow from server code only.");
|
|
57
|
+
}
|
|
58
|
+
if (typeof options.apiKey !== "string" || !KEY.test(options.apiKey.trim())) {
|
|
59
|
+
throw new Error("Bellow: apiKey must be a Bellow key (bk_ followed by 43 characters). Check BELLOW_API_KEY.");
|
|
60
|
+
}
|
|
61
|
+
this.#apiKey = options.apiKey.trim();
|
|
62
|
+
this.#baseUrl = checkBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
63
|
+
this.#from = options.from || void 0;
|
|
64
|
+
this.#replyTo = options.replyTo || void 0;
|
|
65
|
+
this.#timeout = options.timeout ?? 15e3;
|
|
66
|
+
this.#retries = Math.max(0, Math.min(options.retries ?? 2, 5));
|
|
67
|
+
this.#maxRetryAfter = options.maxRetryAfter ?? 30;
|
|
68
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
69
|
+
if (typeof f !== "function") throw new Error("Bellow: no fetch available. Use Node 20+ or pass `fetch`.");
|
|
70
|
+
this.#fetch = f.bind(globalThis);
|
|
71
|
+
}
|
|
72
|
+
/** Where this client sends. Useful in logs; never includes the key. */
|
|
73
|
+
get baseUrl() {
|
|
74
|
+
return this.#baseUrl;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Sends one email. Never throws for an API or network problem: check `result.ok`. Invalid input is
|
|
78
|
+
* reported as `invalid_email` without calling the API.
|
|
79
|
+
*/
|
|
80
|
+
async send(email, options = {}) {
|
|
81
|
+
const resolved = { ...email, from: email.from || this.#from, replyTo: email.replyTo ?? this.#replyTo };
|
|
82
|
+
const issues = validateEmail(resolved);
|
|
83
|
+
if (issues.length) {
|
|
84
|
+
return { ok: false, error: new BellowError("invalid_email", `This email can't be sent: ${issues.map((i) => i.message).join(" ")}`, { details: issues }) };
|
|
85
|
+
}
|
|
86
|
+
const body = JSON.stringify({
|
|
87
|
+
from: resolved.from,
|
|
88
|
+
to: resolved.to,
|
|
89
|
+
cc: list(resolved.cc),
|
|
90
|
+
bcc: list(resolved.bcc),
|
|
91
|
+
reply_to: list(resolved.replyTo),
|
|
92
|
+
subject: resolved.subject,
|
|
93
|
+
html: resolved.html || void 0,
|
|
94
|
+
text: resolved.text || void 0,
|
|
95
|
+
tags: { ...resolved.tags, category: resolved.category }
|
|
96
|
+
});
|
|
97
|
+
const idempotencyKey = options.idempotencyKey ? await toIdempotencyKey(options.idempotencyKey) : randomIdempotencyKey();
|
|
98
|
+
const response = await this.#request("POST", "/api/v1/send", { body, idempotencyKey, signal: options.signal, retries: options.retries });
|
|
99
|
+
if (!response.ok) return response;
|
|
100
|
+
const data = response.data;
|
|
101
|
+
if (typeof data?.id !== "string") return { ok: false, error: new BellowError("server_error", "Bellow answered without a message id.") };
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
id: data.id,
|
|
105
|
+
status: typeof data.status === "string" ? data.status : "sent",
|
|
106
|
+
suppressed: Array.isArray(data.suppressed) ? data.suppressed : [],
|
|
107
|
+
duplicate: data.duplicate === true
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Like `send`, but throws a BellowError instead of returning `{ ok: false }`. */
|
|
111
|
+
async sendOrThrow(email, options) {
|
|
112
|
+
const result = await this.send(email, options);
|
|
113
|
+
if (!result.ok) throw result.error;
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
/** A message sent with this client's key, with its delivery events. Messages of other clients are `not_found`. */
|
|
117
|
+
async getMessage(id, options = {}) {
|
|
118
|
+
if (!/^[0-9a-f-]{36}$/i.test(id)) return { ok: false, error: new BellowError("invalid_request", "That isn't a Bellow message id.") };
|
|
119
|
+
const response = await this.#request("GET", `/api/v1/messages/${id}`, { signal: options.signal });
|
|
120
|
+
if (!response.ok) return response;
|
|
121
|
+
return { ok: true, message: response.data };
|
|
122
|
+
}
|
|
123
|
+
async #request(method, path, { body, idempotencyKey, signal, retries = this.#retries }) {
|
|
124
|
+
let last;
|
|
125
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
126
|
+
if (attempt > 0 && last) {
|
|
127
|
+
const wait = last.retryAfter !== void 0 ? last.retryAfter * 1e3 : Math.min(8e3, 500 * 2 ** (attempt - 1)) + Math.random() * 250;
|
|
128
|
+
try {
|
|
129
|
+
await sleep(wait, signal);
|
|
130
|
+
} catch {
|
|
131
|
+
return { ok: false, error: new BellowError("aborted", "The request was aborted.") };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const outcome = await this.#attempt(method, path, body, idempotencyKey, signal);
|
|
135
|
+
if (outcome.ok) return outcome;
|
|
136
|
+
last = outcome.error;
|
|
137
|
+
const tooLong = last.retryAfter !== void 0 && last.retryAfter > this.#maxRetryAfter;
|
|
138
|
+
if (!last.retryable || tooLong || last.code === "aborted") break;
|
|
139
|
+
}
|
|
140
|
+
return { ok: false, error: last };
|
|
141
|
+
}
|
|
142
|
+
async #attempt(method, path, body, idempotencyKey, signal) {
|
|
143
|
+
const timeout = AbortSignal.timeout(this.#timeout);
|
|
144
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
145
|
+
let res;
|
|
146
|
+
try {
|
|
147
|
+
res = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
148
|
+
method,
|
|
149
|
+
headers: {
|
|
150
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
151
|
+
Accept: "application/json",
|
|
152
|
+
"User-Agent": `bellow-sdk/${SDK_VERSION}`,
|
|
153
|
+
...body ? { "Content-Type": "application/json" } : {},
|
|
154
|
+
...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}
|
|
155
|
+
},
|
|
156
|
+
body,
|
|
157
|
+
// Never follow a redirect: it could carry the Authorization header somewhere else.
|
|
158
|
+
redirect: "error",
|
|
159
|
+
signal: combined
|
|
160
|
+
});
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (signal?.aborted) return { ok: false, error: new BellowError("aborted", "The request was aborted.", { cause: err }) };
|
|
163
|
+
if (timeout.aborted) return { ok: false, error: new BellowError("timeout", `Bellow didn't answer within ${this.#timeout} ms.`, { cause: err }) };
|
|
164
|
+
return { ok: false, error: new BellowError("network_error", `Couldn't reach Bellow: ${err?.message ?? "network error"}`, { cause: err }) };
|
|
165
|
+
}
|
|
166
|
+
const data = await res.json().catch(() => null);
|
|
167
|
+
if (res.ok) return { ok: true, data };
|
|
168
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
169
|
+
const code = data?.error?.code ?? (res.status === 404 ? "not_found" : res.status >= 500 ? "server_error" : "bad_request");
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
error: new BellowError(code, data?.error?.message ?? `Bellow responded ${res.status}.`, {
|
|
173
|
+
status: res.status,
|
|
174
|
+
details: data?.error?.details,
|
|
175
|
+
retryAfter: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0
|
|
176
|
+
})
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
function createBellow(options) {
|
|
181
|
+
return new Bellow(options);
|
|
182
|
+
}
|
|
183
|
+
function createBellowFromEnv(env = globalThis.process?.env ?? {}, overrides = {}) {
|
|
184
|
+
const apiKey = env.BELLOW_API_KEY?.trim();
|
|
185
|
+
const from = env.EMAIL_FROM?.trim();
|
|
186
|
+
if (!apiKey || !from) return null;
|
|
187
|
+
return new Bellow({ apiKey, from, replyTo: env.EMAIL_REPLY_TO?.trim() || void 0, baseUrl: env.BELLOW_URL?.trim() || void 0, ...overrides });
|
|
188
|
+
}
|
|
189
|
+
export {
|
|
190
|
+
Bellow,
|
|
191
|
+
BellowError,
|
|
192
|
+
DEFAULT_BASE_URL,
|
|
193
|
+
LIMITS,
|
|
194
|
+
SDK_VERSION,
|
|
195
|
+
createBellow,
|
|
196
|
+
createBellowFromEnv,
|
|
197
|
+
isBellowError,
|
|
198
|
+
parseAddress,
|
|
199
|
+
toIdempotencyKey,
|
|
200
|
+
validateEmail
|
|
201
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { E as Email, b as BellowErrorCode, a as BellowError, g as SendOptions, d as BellowSender } from './client-BgpTyZ0a.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @the-smithy/bellow/testing: a stand-in for the Bellow client in your app's tests.
|
|
5
|
+
*
|
|
6
|
+
* const mock = createMockBellow({ from: "Acme <hello@acme.com>" });
|
|
7
|
+
* await signUp(mock.client, user); // your code takes a BellowSender
|
|
8
|
+
* expect(mock.sent[0].email.category).toBe("welcome");
|
|
9
|
+
*
|
|
10
|
+
* It applies the same validation as the real client, so a malformed email fails in tests too.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type SentEmail = {
|
|
14
|
+
id: string;
|
|
15
|
+
email: Email & {
|
|
16
|
+
from: string;
|
|
17
|
+
};
|
|
18
|
+
options: SendOptions;
|
|
19
|
+
};
|
|
20
|
+
type MockBellowOptions = {
|
|
21
|
+
/** Default sender, like the real client's `from`. */
|
|
22
|
+
from?: string;
|
|
23
|
+
replyTo?: string;
|
|
24
|
+
/** Return an error code (or BellowError) to make a send fail, e.g. `() => "rate_limited"`. */
|
|
25
|
+
fail?: (email: Email) => BellowErrorCode | BellowError | undefined;
|
|
26
|
+
/** Addresses to report as suppressed (skipped) when sent to. */
|
|
27
|
+
suppressed?: string[];
|
|
28
|
+
};
|
|
29
|
+
declare function createMockBellow(options?: MockBellowOptions): {
|
|
30
|
+
client: BellowSender;
|
|
31
|
+
/** Every email accepted so far, oldest first. */
|
|
32
|
+
sent: SentEmail[];
|
|
33
|
+
/** Forget everything sent. */
|
|
34
|
+
reset(): void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export { type MockBellowOptions, type SentEmail, createMockBellow };
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BellowError,
|
|
3
|
+
parseAddress,
|
|
4
|
+
validateEmail
|
|
5
|
+
} from "./chunk-IO7QZUKG.js";
|
|
6
|
+
|
|
7
|
+
// src/testing.ts
|
|
8
|
+
function createMockBellow(options = {}) {
|
|
9
|
+
const sent = [];
|
|
10
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
11
|
+
let counter = 0;
|
|
12
|
+
const client = {
|
|
13
|
+
async send(email, sendOptions = {}) {
|
|
14
|
+
const resolved = { ...email, from: email.from || options.from, replyTo: email.replyTo ?? options.replyTo };
|
|
15
|
+
const issues = validateEmail(resolved);
|
|
16
|
+
if (issues.length) {
|
|
17
|
+
return { ok: false, error: new BellowError("invalid_email", `This email can't be sent: ${issues.map((i) => i.message).join(" ")}`, { details: issues }) };
|
|
18
|
+
}
|
|
19
|
+
const failure = options.fail?.(email);
|
|
20
|
+
if (failure) return { ok: false, error: typeof failure === "string" ? new BellowError(failure, `Mock failure: ${failure}`) : failure };
|
|
21
|
+
const key = sendOptions.idempotencyKey;
|
|
22
|
+
if (key && byKey.has(key)) return { ok: true, id: byKey.get(key), status: "sent", suppressed: [], duplicate: true };
|
|
23
|
+
const id = `00000000-0000-4000-8000-${String(++counter).padStart(12, "0")}`;
|
|
24
|
+
if (key) byKey.set(key, id);
|
|
25
|
+
sent.push({ id, email: { ...resolved, from: resolved.from }, options: sendOptions });
|
|
26
|
+
const recipients = new Set([resolved.to, resolved.cc ?? [], resolved.bcc ?? []].flat().map((a) => parseAddress(a)));
|
|
27
|
+
const suppressed = (options.suppressed ?? []).map((a) => parseAddress(a) ?? a.toLowerCase()).filter((a) => recipients.has(a));
|
|
28
|
+
return { ok: true, id, status: "sent", suppressed, duplicate: false };
|
|
29
|
+
},
|
|
30
|
+
async getMessage(id) {
|
|
31
|
+
const found = sent.find((s) => s.id === id);
|
|
32
|
+
if (!found) return { ok: false, error: new BellowError("not_found", "No such message.", { status: 404 }) };
|
|
33
|
+
const asList = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
34
|
+
const message = {
|
|
35
|
+
id,
|
|
36
|
+
status: "sent",
|
|
37
|
+
from: found.email.from,
|
|
38
|
+
to: asList(found.email.to),
|
|
39
|
+
cc: asList(found.email.cc),
|
|
40
|
+
bcc: asList(found.email.bcc),
|
|
41
|
+
subject: found.email.subject,
|
|
42
|
+
tags: { ...found.email.tags, category: found.email.category },
|
|
43
|
+
createdAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
44
|
+
events: []
|
|
45
|
+
};
|
|
46
|
+
return { ok: true, message };
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
client,
|
|
51
|
+
/** Every email accepted so far, oldest first. */
|
|
52
|
+
sent,
|
|
53
|
+
/** Forget everything sent. */
|
|
54
|
+
reset() {
|
|
55
|
+
sent.length = 0;
|
|
56
|
+
byKey.clear();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export {
|
|
61
|
+
createMockBellow
|
|
62
|
+
};
|
package/llms.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @the-smithy/bellow: reference for AI assistants
|
|
2
|
+
|
|
3
|
+
Bellow is The Smithy's transactional email service (Amazon SES behind `https://bellow.thesmithy.io`). This
|
|
4
|
+
package is the **only** sanctioned way for a Smithy app to send email. It's server-only: never import it in
|
|
5
|
+
client components or browser code, since the key can send email as the client's domains.
|
|
6
|
+
|
|
7
|
+
## Before writing code, confirm with the human
|
|
8
|
+
- The client exists in the Bellow dashboard, with its sending domain **verified**.
|
|
9
|
+
- An API key exists for this app and environment and is set as the secret env var `BELLOW_API_KEY`. Never ask
|
|
10
|
+
for the key in chat, and never write it to a file.
|
|
11
|
+
- Which address to test with: while SES is in the sandbox, only verified addresses receive mail.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
- `pnpm add @the-smithy/bellow`: public on npm, no `.npmrc` entry or token needed.
|
|
15
|
+
- Use pnpm, not npm or npx, in Smithy repos.
|
|
16
|
+
|
|
17
|
+
## Env
|
|
18
|
+
`BELLOW_API_KEY` (required, secret), `EMAIL_FROM` (required, `Name <addr@verified-domain>`),
|
|
19
|
+
`EMAIL_REPLY_TO` (optional), `BELLOW_URL` (optional, non-production only).
|
|
20
|
+
|
|
21
|
+
## Setup (one module)
|
|
22
|
+
```ts
|
|
23
|
+
// lib/email.ts
|
|
24
|
+
import "server-only";
|
|
25
|
+
import { createBellowFromEnv } from "@the-smithy/bellow";
|
|
26
|
+
export const bellow = createBellowFromEnv(); // null when not configured: treat email as off and fall back
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Send
|
|
30
|
+
```ts
|
|
31
|
+
const result = await bellow?.send(
|
|
32
|
+
{ to, subject, html, text, category: "password-reset", tags?: { plan: "pro" }, cc?, bcc?, replyTo?, from? },
|
|
33
|
+
{ idempotencyKey?: "password-reset-<userId>-<window>", retries?, signal? },
|
|
34
|
+
);
|
|
35
|
+
// result: undefined (not configured) | { ok: true, id, status, suppressed, duplicate } | { ok: false, error: BellowError }
|
|
36
|
+
```
|
|
37
|
+
- `category` is required: letters, numbers, `_`, `-`. Pick a stable kebab-case name per kind of email.
|
|
38
|
+
- Addresses: `a@b.com` or `Name <a@b.com>`; one per entry; arrays for several; no duplicates across to/cc/bcc;
|
|
39
|
+
50 recipients max.
|
|
40
|
+
- Subject is one line. Send both `html` and `text`. Subject, html and text total 512 KB or less.
|
|
41
|
+
- Tags: up to 9 besides category; names can't start with `bellow`; no `category` tag (use the field).
|
|
42
|
+
- `send` never throws for send problems. `sendOrThrow` throws `BellowError`. Invalid input → `invalid_email`
|
|
43
|
+
with `error.details: { field, message }[]`, and no request is made.
|
|
44
|
+
- Idempotency: always pass a key built from stable ids for anything retryable (webhooks, jobs, buttons). Any
|
|
45
|
+
string is fine (unsafe ones are hashed). Without one the SDK generates a key per call.
|
|
46
|
+
- The SDK retries `network_error`, `timeout`, `rate_limited` (when Retry-After ≤ 30s), `unavailable` and
|
|
47
|
+
`server_error`, 2 retries with backoff, reusing the same idempotency key.
|
|
48
|
+
|
|
49
|
+
## Errors (`BellowError`: code, message, status, details, retryAfter, retryable)
|
|
50
|
+
`invalid_email`, `invalid_request` (local) · `bad_request` 400 · `unauthorized` 401 (key missing/revoked) ·
|
|
51
|
+
`forbidden` 403 (from domain not verified, or client paused) · `not_found` 404 · `conflict` 409 (key reused for
|
|
52
|
+
a different email) · `unprocessable` 422 (all To suppressed) · `rate_limited` 429 · `unavailable` 503 ·
|
|
53
|
+
`server_error` 500 · `network_error` · `timeout` · `aborted`.
|
|
54
|
+
Log `code` and `message`; never log the key or email bodies; don't show raw messages to site visitors.
|
|
55
|
+
|
|
56
|
+
## Status
|
|
57
|
+
`await bellow.getMessage(id)` → `{ ok: true, message: { id, status, from, to, cc, bcc, subject, tags, createdAt, events[] } }`.
|
|
58
|
+
Status: `queued`, `sent`, `delivered`, `delivery_delayed`, `bounced`, `complained`, `rejected`, `failed`.
|
|
59
|
+
|
|
60
|
+
## Testing
|
|
61
|
+
```ts
|
|
62
|
+
import { createMockBellow } from "@the-smithy/bellow/testing";
|
|
63
|
+
const mock = createMockBellow({ from: "Acme <hello@acme.com>", fail?: (email) => "rate_limited" | undefined, suppressed?: [...] });
|
|
64
|
+
// pass mock.client wherever your code takes a BellowSender; inspect mock.sent; mock.reset()
|
|
65
|
+
```
|
|
66
|
+
Type your own email helpers against `BellowSender` (from the main entry) so tests can inject the mock.
|
|
67
|
+
|
|
68
|
+
## Rules
|
|
69
|
+
- One `bellow` instance per app, in a server-only module. Everything that sends goes through it.
|
|
70
|
+
- Public forms: never let a visitor pick both the recipient and the content. Send to a fixed owner address,
|
|
71
|
+
or a fixed template to the visitor's own address, behind rate limiting or a CAPTCHA.
|
|
72
|
+
- When replacing another provider (Resend, SendGrid, nodemailer): keep the app's existing `sendEmail()` shape
|
|
73
|
+
if callers depend on it, implement it with `bellow.send`, map its "category"/tag concept to `category`,
|
|
74
|
+
remove the old dependency and env vars, and keep the "not configured → skip and fall back" behavior.
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@the-smithy/bellow",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The standard way for The Smithy's apps to send transactional email through Bellow: validated, retried, idempotent, server-only.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/The-Smithy/bellow.git",
|
|
10
|
+
"directory": "packages/bellow"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20.3"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./testing": {
|
|
22
|
+
"types": "./dist/testing.d.ts",
|
|
23
|
+
"import": "./dist/testing.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"llms.md",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.20.5",
|
|
38
|
+
"tsup": "^8.5.1",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"email",
|
|
43
|
+
"transactional",
|
|
44
|
+
"ses",
|
|
45
|
+
"bellow",
|
|
46
|
+
"the-smithy"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"test": "node --test --experimental-strip-types --disable-warning=ExperimentalWarning \"test/**/*.test.ts\"",
|
|
51
|
+
"typecheck": "tsc --noEmit"
|
|
52
|
+
}
|
|
6
53
|
}
|