mailchannels-sdk 0.7.10 โ 0.7.11
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/README.md +66 -41
- package/dist/_chunks/simulator.mjs +743 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +50 -0
- package/dist/mailchannels.mjs +10 -1
- package/package.json +11 -8
package/README.md
CHANGED
|
@@ -18,13 +18,13 @@ This library provides a simple way to interact with the [MailChannels API](https
|
|
|
18
18
|
## Contents
|
|
19
19
|
|
|
20
20
|
- ๐ [Features](#features)
|
|
21
|
-
- ๐ [
|
|
21
|
+
- ๐ [Prerequisites](#prerequisites)
|
|
22
22
|
- ๐ฆ [Installation](#installation)
|
|
23
23
|
- ๐ [Usage](#usage)
|
|
24
24
|
- ๐ [Naming Conventions](#naming-conventions)
|
|
25
|
+
- ๐งช [Local simulator](#local-simulator)
|
|
25
26
|
- โ๏ธ [License](#license)
|
|
26
27
|
- ๐ป [Development](#development)
|
|
27
|
-
- ๐งช [Local simulator](#local-simulator)
|
|
28
28
|
|
|
29
29
|
## <a name="features">๐ Features</a>
|
|
30
30
|
|
|
@@ -41,6 +41,7 @@ Some of the things you can do with the SDK:
|
|
|
41
41
|
- Retrieve metrics
|
|
42
42
|
- Inspect webhook delivery batches
|
|
43
43
|
- Handle suppressions
|
|
44
|
+
- Run a local simulator for development testing
|
|
44
45
|
- Configure inbound domains
|
|
45
46
|
- Manage account and recipient lists
|
|
46
47
|
|
|
@@ -48,7 +49,7 @@ Some of the things you can do with the SDK:
|
|
|
48
49
|
> For a detailed reference mapping each SDK method to its corresponding MailChannels API endpoint reference, see the [SDK-API Mapping](https://mailchannels.yizack.com/sdk-api-mapping)
|
|
49
50
|
<!-- #endregion features -->
|
|
50
51
|
|
|
51
|
-
## <a name="
|
|
52
|
+
## <a name="prerequisites">๐ Prerequisites</a>
|
|
52
53
|
|
|
53
54
|
- [Create a MailChannels account](https://www.mailchannels.com/pricing/#for_devs)
|
|
54
55
|
- [Create an API key](https://console.mailchannels.net/settings/accountSettings#APIKeys)
|
|
@@ -101,59 +102,44 @@ Most properties in the MailChannels API use `snake_case`. To follow JavaScript c
|
|
|
101
102
|
- While most fields match the API docs (just with `camelCase`), a few may be simplified or reorganized to feel more natural for JavaScript developers.
|
|
102
103
|
<!-- #endregion naming-conventions -->
|
|
103
104
|
|
|
104
|
-
## <a name="
|
|
105
|
-
|
|
106
|
-
[MIT License](LICENSE)
|
|
107
|
-
|
|
108
|
-
## <a name="development">๐ป Development</a>
|
|
109
|
-
|
|
110
|
-
<details>
|
|
111
|
-
<summary>Local development</summary>
|
|
112
|
-
|
|
113
|
-
```sh
|
|
114
|
-
# Install dependencies
|
|
115
|
-
pnpm install
|
|
116
|
-
|
|
117
|
-
# Build the package
|
|
118
|
-
pnpm build
|
|
119
|
-
|
|
120
|
-
# Run Oxlint
|
|
121
|
-
pnpm lint
|
|
105
|
+
## <a name="local-simulator">๐งช Local simulator</a>
|
|
122
106
|
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
pnpm test:watch
|
|
107
|
+
<!-- #region simulator -->
|
|
108
|
+
This package includes a local MailChannels simulator you can run via the CLI. It holds state in memory and emulates the SDK-supported endpoints, letting you develop and test your application locally without hitting the real MailChannels service.
|
|
126
109
|
|
|
127
|
-
|
|
128
|
-
|
|
110
|
+
| API | Source |
|
|
111
|
+
| ----------- | -------------------------------------------------------------------------------------------------------------- |
|
|
112
|
+
| Email API | [`src/simulator/email-api.mjs`](https://github.com/Yizack/mailchannels/blob/main/src/simulator/email-api.mjs) |
|
|
113
|
+
| Inbound API | N/A |
|
|
129
114
|
|
|
130
|
-
|
|
131
|
-
|
|
115
|
+
> [!IMPORTANT]
|
|
116
|
+
> The simulator approximates the MailChannels service for local development and testing. It is not a production implementation and may differ from the live service.
|
|
132
117
|
|
|
133
|
-
|
|
134
|
-
pnpm simulate:email-api
|
|
118
|
+
### Start the simulator
|
|
135
119
|
|
|
136
|
-
|
|
137
|
-
|
|
120
|
+
```sh
|
|
121
|
+
# default: http://127.0.0.1:8787
|
|
122
|
+
npx mailchannels-sdk simulate
|
|
138
123
|
```
|
|
139
124
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
## <a name="local-simulator">๐งช Local simulator</a>
|
|
125
|
+
### Options
|
|
143
126
|
|
|
144
|
-
|
|
127
|
+
| Option | Description | Default |
|
|
128
|
+
| ----------------- | ----------------------- | ----------- |
|
|
129
|
+
| `--port <number>` | Port to listen on | `8787` |
|
|
130
|
+
| `--host <host>` | Host address | `127.0.0.1` |
|
|
131
|
+
| `--silent` | Suppress simulator logs | `false` |
|
|
145
132
|
|
|
146
|
-
|
|
133
|
+
You can override the bind address with the `--host` and `--port` options:
|
|
147
134
|
|
|
148
135
|
```sh
|
|
149
|
-
|
|
150
|
-
pnpm simulate:email-api
|
|
136
|
+
npx mailchannels-sdk simulate --host 127.0.0.1 --port 8787
|
|
151
137
|
```
|
|
152
138
|
|
|
153
|
-
|
|
139
|
+
Disable logs with the `--silent` option:
|
|
154
140
|
|
|
155
141
|
```sh
|
|
156
|
-
|
|
142
|
+
npx mailchannels-sdk simulate --silent
|
|
157
143
|
```
|
|
158
144
|
|
|
159
145
|
### Point the SDK at the simulator
|
|
@@ -192,6 +178,45 @@ const { data, error } = await mailchannels.emails.send({
|
|
|
192
178
|
- Webhook responses are simulated locally, but the simulator does not yet emit real webhook callbacks to your application
|
|
193
179
|
|
|
194
180
|
The next planned expansion is outbound webhook delivery so client applications can test webhook ingestion flows against the simulator as well.
|
|
181
|
+
<!-- #endregion simulator -->
|
|
182
|
+
|
|
183
|
+
## <a name="license">โ๏ธ License</a>
|
|
184
|
+
|
|
185
|
+
[MIT License](LICENSE)
|
|
186
|
+
|
|
187
|
+
## <a name="development">๐ป Development</a>
|
|
188
|
+
|
|
189
|
+
<details>
|
|
190
|
+
<summary>Local development</summary>
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
# Install dependencies
|
|
194
|
+
pnpm install
|
|
195
|
+
|
|
196
|
+
# Build the package
|
|
197
|
+
pnpm build
|
|
198
|
+
|
|
199
|
+
# Run Oxlint
|
|
200
|
+
pnpm lint
|
|
201
|
+
|
|
202
|
+
# Run Vitest
|
|
203
|
+
pnpm test
|
|
204
|
+
pnpm test:watch
|
|
205
|
+
|
|
206
|
+
# Run typecheck
|
|
207
|
+
pnpm test:types
|
|
208
|
+
|
|
209
|
+
# Refresh API parity fixtures
|
|
210
|
+
pnpm parity:fixtures
|
|
211
|
+
|
|
212
|
+
# Run the local simulator
|
|
213
|
+
pnpm simulate
|
|
214
|
+
|
|
215
|
+
# Release new version
|
|
216
|
+
pnpm release
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
</details>
|
|
195
220
|
|
|
196
221
|
<!-- Badges -->
|
|
197
222
|
[npm-version-src]: https://img.shields.io/npm/v/mailchannels-sdk.svg?style=flat&colorA=070a30&colorB=35a047
|
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
const SIMULATOR_SIGNING_KEY_ID = "simulator-default";
|
|
4
|
+
const JSON_HEADERS = { "content-type": "application/json" };
|
|
5
|
+
const currentTimestamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
6
|
+
const createId = (prefix) => `${prefix}_${randomBytes(6).toString("hex")}`;
|
|
7
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
8
|
+
const readBody = async (request) => {
|
|
9
|
+
const chunks = [];
|
|
10
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
11
|
+
if (!chunks.length) return null;
|
|
12
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
13
|
+
if (!raw) return null;
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
};
|
|
16
|
+
const sendJson = (response, statusCode, body) => {
|
|
17
|
+
response.writeHead(statusCode, JSON_HEADERS);
|
|
18
|
+
response.end(JSON.stringify(body));
|
|
19
|
+
};
|
|
20
|
+
const sendNoContent = (response) => {
|
|
21
|
+
response.writeHead(204);
|
|
22
|
+
response.end();
|
|
23
|
+
};
|
|
24
|
+
const sendText = (response, statusCode, message) => {
|
|
25
|
+
response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8" });
|
|
26
|
+
response.end(message);
|
|
27
|
+
};
|
|
28
|
+
const getArrayQuery = (url, name) => {
|
|
29
|
+
return url.searchParams.getAll(name).flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
|
|
30
|
+
};
|
|
31
|
+
const renderTemplate = (value, data = {}) => value.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (_, key) => {
|
|
32
|
+
const replacement = data[key];
|
|
33
|
+
return replacement === void 0 || replacement === null ? "" : String(replacement);
|
|
34
|
+
});
|
|
35
|
+
const buildRenderedMessage = (payload, personalization) => {
|
|
36
|
+
const recipientList = (personalization.to || []).map((recipient) => recipient.email).join(", ");
|
|
37
|
+
const subject = personalization.subject || payload.subject;
|
|
38
|
+
const templateData = {
|
|
39
|
+
...payload.personalizations?.[0]?.dynamic_template_data || {},
|
|
40
|
+
...personalization.dynamic_template_data || {}
|
|
41
|
+
};
|
|
42
|
+
const content = (payload.content || []).map((item) => renderTemplate(item.value, templateData)).join("\n\n");
|
|
43
|
+
return [
|
|
44
|
+
`From: ${personalization.from?.email || payload.from?.email || "sender@example.com"}`,
|
|
45
|
+
`To: ${recipientList}`,
|
|
46
|
+
`Subject: ${subject}`,
|
|
47
|
+
"",
|
|
48
|
+
content
|
|
49
|
+
].join("\n");
|
|
50
|
+
};
|
|
51
|
+
const createMetricsBuckets = (count) => [{
|
|
52
|
+
count,
|
|
53
|
+
period_start: currentTimestamp()
|
|
54
|
+
}];
|
|
55
|
+
const createAccountState = (apiKey) => ({
|
|
56
|
+
apiKey,
|
|
57
|
+
customerHandle: createId("customer"),
|
|
58
|
+
dkimKeysByDomain: /* @__PURE__ */ new Map(),
|
|
59
|
+
messages: [],
|
|
60
|
+
subAccounts: /* @__PURE__ */ new Map(),
|
|
61
|
+
suppressionEntries: [],
|
|
62
|
+
webhookBatches: [],
|
|
63
|
+
webhooks: /* @__PURE__ */ new Set(),
|
|
64
|
+
webhookSigningKeys: new Map([[SIMULATOR_SIGNING_KEY_ID, "SIMULATOR_PUBLIC_SIGNING_KEY"]]),
|
|
65
|
+
nextIds: {
|
|
66
|
+
apiKey: 1,
|
|
67
|
+
batch: 1,
|
|
68
|
+
smtpPassword: 1,
|
|
69
|
+
subAccount: 1
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const createSubAccount = (account, companyName, handle) => ({
|
|
73
|
+
apiKeys: [],
|
|
74
|
+
company_name: companyName,
|
|
75
|
+
enabled: true,
|
|
76
|
+
handle: handle || `subaccount${account.nextIds.subAccount++}`,
|
|
77
|
+
limit: null,
|
|
78
|
+
smtpPasswords: [],
|
|
79
|
+
usage: 0
|
|
80
|
+
});
|
|
81
|
+
const toDkimDnsRecord = (selector, domain, value) => ({
|
|
82
|
+
name: `${selector}._domainkey.${domain}`,
|
|
83
|
+
type: "TXT",
|
|
84
|
+
value: `v=DKIM1; k=rsa; p=${value}`
|
|
85
|
+
});
|
|
86
|
+
const createDkimKey = (domain, selector, overrides = {}) => {
|
|
87
|
+
const publicKey = Buffer.from(`${domain}:${selector}:public-key`).toString("base64");
|
|
88
|
+
return {
|
|
89
|
+
algorithm: "rsa",
|
|
90
|
+
created_at: currentTimestamp(),
|
|
91
|
+
dkim_dns_records: [toDkimDnsRecord(selector, domain, publicKey)],
|
|
92
|
+
domain,
|
|
93
|
+
gracePeriodExpiresAt: null,
|
|
94
|
+
key_length: 2048,
|
|
95
|
+
public_key: publicKey,
|
|
96
|
+
retiresAt: null,
|
|
97
|
+
selector,
|
|
98
|
+
status: "active",
|
|
99
|
+
status_modified_at: currentTimestamp(),
|
|
100
|
+
...overrides
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const listDkimKeys = (account, domain) => account.dkimKeysByDomain.get(domain) || [];
|
|
104
|
+
const recordWebhookBatch = (account, webhook, eventCount, status = "2xx_response", statusCode = 200) => {
|
|
105
|
+
account.webhookBatches.unshift({
|
|
106
|
+
batch_id: account.nextIds.batch++,
|
|
107
|
+
created_at: currentTimestamp(),
|
|
108
|
+
customer_handle: account.customerHandle,
|
|
109
|
+
duration: {
|
|
110
|
+
unit: "milliseconds",
|
|
111
|
+
value: 25
|
|
112
|
+
},
|
|
113
|
+
event_count: eventCount,
|
|
114
|
+
status,
|
|
115
|
+
status_code: statusCode,
|
|
116
|
+
webhook
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
const collectMessages = (account, filters = {}) => {
|
|
120
|
+
return account.messages.filter((message) => {
|
|
121
|
+
if (filters.campaignId && message.campaignId !== filters.campaignId) return false;
|
|
122
|
+
if (filters.scopeHandle !== void 0 && message.scopeHandle !== filters.scopeHandle) return false;
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
const summarizeMessages = (messages) => ({
|
|
127
|
+
bounced: messages.reduce((total, message) => total + message.bounced, 0),
|
|
128
|
+
click: messages.reduce((total, message) => total + message.click, 0),
|
|
129
|
+
clickTrackingDelivered: messages.reduce((total, message) => total + message.clickTrackingDelivered, 0),
|
|
130
|
+
delivered: messages.reduce((total, message) => total + message.delivered, 0),
|
|
131
|
+
dropped: messages.reduce((total, message) => total + message.dropped, 0),
|
|
132
|
+
open: messages.reduce((total, message) => total + message.open, 0),
|
|
133
|
+
openTrackingDelivered: messages.reduce((total, message) => total + message.openTrackingDelivered, 0),
|
|
134
|
+
processed: messages.reduce((total, message) => total + message.processed, 0),
|
|
135
|
+
unsubscribeDelivered: messages.reduce((total, message) => total + message.unsubscribeDelivered, 0),
|
|
136
|
+
unsubscribed: messages.reduce((total, message) => total + message.unsubscribed, 0)
|
|
137
|
+
});
|
|
138
|
+
const createScope = () => ({
|
|
139
|
+
account: null,
|
|
140
|
+
handle: null,
|
|
141
|
+
type: "parent"
|
|
142
|
+
});
|
|
143
|
+
const createEmailApiHandler = ({ logRequests = true } = {}) => {
|
|
144
|
+
const accounts = /* @__PURE__ */ new Map();
|
|
145
|
+
const apiKeyScopes = /* @__PURE__ */ new Map();
|
|
146
|
+
const ensureParentScope = (apiKey) => {
|
|
147
|
+
if (apiKeyScopes.has(apiKey)) return apiKeyScopes.get(apiKey);
|
|
148
|
+
const account = createAccountState(apiKey);
|
|
149
|
+
accounts.set(account.customerHandle, account);
|
|
150
|
+
const scope = createScope();
|
|
151
|
+
scope.account = account;
|
|
152
|
+
apiKeyScopes.set(apiKey, scope);
|
|
153
|
+
return scope;
|
|
154
|
+
};
|
|
155
|
+
const resolveScope = (apiKey) => {
|
|
156
|
+
const scope = ensureParentScope(apiKey);
|
|
157
|
+
return {
|
|
158
|
+
account: scope.account,
|
|
159
|
+
scopeHandle: scope.handle
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
const notFound = (response) => sendJson(response, 404, { error: "Not Found" });
|
|
163
|
+
const handler = async (request, response) => {
|
|
164
|
+
try {
|
|
165
|
+
const method = request.method || "GET";
|
|
166
|
+
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
|
|
167
|
+
if (logRequests) console.info("[Simulator]", `${method} ${url.pathname}`);
|
|
168
|
+
const publicPaths = ["/tx/v1/webhook/public-key"];
|
|
169
|
+
const apiKey = request.headers["x-api-key"];
|
|
170
|
+
if (!publicPaths.includes(url.pathname) && (typeof apiKey !== "string" || !apiKey)) {
|
|
171
|
+
sendJson(response, 401, { error: "Missing X-API-Key header." });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const { account, scopeHandle } = resolveScope(apiKey);
|
|
175
|
+
const body = await readBody(request).catch(() => void 0);
|
|
176
|
+
if (body === void 0) {
|
|
177
|
+
sendJson(response, 400, { error: "Invalid JSON request body." });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (method === "POST" && url.pathname === "/tx/v1/send") {
|
|
181
|
+
const requestId = createId("request");
|
|
182
|
+
const personalizations = body?.personalizations || [];
|
|
183
|
+
const eventCount = personalizations.length;
|
|
184
|
+
if (url.searchParams.get("dry-run") === "true") {
|
|
185
|
+
sendJson(response, 200, {
|
|
186
|
+
data: personalizations.map((personalization) => buildRenderedMessage(body, personalization)),
|
|
187
|
+
request_id: requestId
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const results = personalizations.map((personalization, index) => {
|
|
192
|
+
const messageId = `<${randomUUID()}@simulator.mailchannels.local>`;
|
|
193
|
+
const recipientCount = [
|
|
194
|
+
...personalization.to || [],
|
|
195
|
+
...personalization.cc || [],
|
|
196
|
+
...personalization.bcc || []
|
|
197
|
+
].length;
|
|
198
|
+
account.messages.push({
|
|
199
|
+
bounced: 0,
|
|
200
|
+
campaignId: body?.campaign_id || "uncategorized",
|
|
201
|
+
click: body?.tracking_settings?.click_tracking?.enable ? 1 : 0,
|
|
202
|
+
clickTrackingDelivered: body?.tracking_settings?.click_tracking?.enable ? 1 : 0,
|
|
203
|
+
delivered: 1,
|
|
204
|
+
dropped: 0,
|
|
205
|
+
open: body?.tracking_settings?.open_tracking?.enable ? 1 : 0,
|
|
206
|
+
openTrackingDelivered: body?.tracking_settings?.open_tracking?.enable ? 1 : 0,
|
|
207
|
+
processed: 1,
|
|
208
|
+
recipientCount,
|
|
209
|
+
requestId,
|
|
210
|
+
scopeHandle,
|
|
211
|
+
timestamp: currentTimestamp(),
|
|
212
|
+
unsubscribeDelivered: body?.transactional === false ? 1 : 0,
|
|
213
|
+
unsubscribed: 0
|
|
214
|
+
});
|
|
215
|
+
if (scopeHandle) {
|
|
216
|
+
const subAccount = account.subAccounts.get(scopeHandle);
|
|
217
|
+
if (subAccount) subAccount.usage += 1;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
index,
|
|
221
|
+
message_id: messageId,
|
|
222
|
+
status: "sent"
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
for (const webhook of account.webhooks) recordWebhookBatch(account, webhook, eventCount);
|
|
226
|
+
sendJson(response, 200, {
|
|
227
|
+
request_id: requestId,
|
|
228
|
+
results
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (method === "POST" && url.pathname === "/tx/v1/send-async") {
|
|
233
|
+
const eventCount = body?.personalizations?.length || 0;
|
|
234
|
+
for (const webhook of account.webhooks) recordWebhookBatch(account, webhook, eventCount);
|
|
235
|
+
sendJson(response, 202, {
|
|
236
|
+
queued_at: currentTimestamp(),
|
|
237
|
+
request_id: createId("request")
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (method === "POST" && url.pathname === "/tx/v1/check-domain") {
|
|
242
|
+
const domain = body?.domain || "example.com";
|
|
243
|
+
sendJson(response, 200, { check_results: {
|
|
244
|
+
dkim: (body?.dkim_settings?.length ? body.dkim_settings : listDkimKeys(account, domain).map((key) => ({
|
|
245
|
+
dkim_domain: key.domain,
|
|
246
|
+
dkim_selector: key.selector
|
|
247
|
+
}))).map((setting) => ({
|
|
248
|
+
dkim_domain: setting.dkim_domain || domain,
|
|
249
|
+
dkim_key_status: setting.dkim_private_key ? "provided" : listDkimKeys(account, setting.dkim_domain || domain).find((key) => key.selector === setting.dkim_selector)?.status || "active",
|
|
250
|
+
dkim_selector: setting.dkim_selector || "default",
|
|
251
|
+
verdict: "passed"
|
|
252
|
+
})),
|
|
253
|
+
domain_lockdown: { verdict: "passed" },
|
|
254
|
+
sender_domain: {
|
|
255
|
+
a: { verdict: "passed" },
|
|
256
|
+
mx: { verdict: "passed" },
|
|
257
|
+
verdict: "passed"
|
|
258
|
+
},
|
|
259
|
+
spf: {
|
|
260
|
+
verdict: "passed",
|
|
261
|
+
spfRecord: "v=spf1 a mx include:relay.mailchannels.local ~all"
|
|
262
|
+
}
|
|
263
|
+
} });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const dkimCollectionMatch = url.pathname.match(/^\/tx\/v1\/domains\/([^/]+)\/dkim-keys$/);
|
|
267
|
+
if (dkimCollectionMatch) {
|
|
268
|
+
const [, domain] = dkimCollectionMatch;
|
|
269
|
+
if (method === "POST") {
|
|
270
|
+
const key = createDkimKey(domain, body?.selector || createId("selector"), {
|
|
271
|
+
algorithm: body?.algorithm || "rsa",
|
|
272
|
+
key_length: body?.key_length || 2048
|
|
273
|
+
});
|
|
274
|
+
account.dkimKeysByDomain.set(domain, [...listDkimKeys(account, domain), key]);
|
|
275
|
+
sendJson(response, 201, key);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (method === "GET") {
|
|
279
|
+
const selector = url.searchParams.get("selector");
|
|
280
|
+
const status = url.searchParams.get("status");
|
|
281
|
+
const offset = Number(url.searchParams.get("offset") || "0");
|
|
282
|
+
const limit = Number(url.searchParams.get("limit") || "10");
|
|
283
|
+
let keys = listDkimKeys(account, domain);
|
|
284
|
+
if (selector) keys = keys.filter((key) => key.selector === selector);
|
|
285
|
+
if (status) keys = keys.filter((key) => key.status === status);
|
|
286
|
+
sendJson(response, 200, { keys: keys.slice(offset, offset + limit) });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const rotateDkimMatch = url.pathname.match(/^\/tx\/v1\/domains\/([^/]+)\/dkim-keys\/([^/]+)\/rotate$/);
|
|
291
|
+
if (rotateDkimMatch && method === "POST") {
|
|
292
|
+
const [, domain, selector] = rotateDkimMatch;
|
|
293
|
+
const keys = listDkimKeys(account, domain);
|
|
294
|
+
const targetKey = keys.find((key) => key.selector === selector);
|
|
295
|
+
if (!targetKey) {
|
|
296
|
+
sendJson(response, 404, { error: "Specified key pair not found." });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
targetKey.status = "rotated";
|
|
300
|
+
targetKey.status_modified_at = currentTimestamp();
|
|
301
|
+
targetKey.gracePeriodExpiresAt = new Date(Date.now() + 10080 * 60 * 1e3).toISOString();
|
|
302
|
+
targetKey.retiresAt = new Date(Date.now() + 720 * 60 * 60 * 1e3).toISOString();
|
|
303
|
+
const newKey = createDkimKey(domain, body?.new_key?.selector || createId("selector"));
|
|
304
|
+
account.dkimKeysByDomain.set(domain, [...keys, newKey]);
|
|
305
|
+
sendJson(response, 201, {
|
|
306
|
+
new_key: newKey,
|
|
307
|
+
rotated_key: targetKey
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const dkimItemMatch = url.pathname.match(/^\/tx\/v1\/domains\/([^/]+)\/dkim-keys\/([^/]+)$/);
|
|
312
|
+
if (dkimItemMatch && method === "PATCH") {
|
|
313
|
+
const [, domain, selector] = dkimItemMatch;
|
|
314
|
+
const targetKey = listDkimKeys(account, domain).find((key) => key.selector === selector);
|
|
315
|
+
if (!targetKey) {
|
|
316
|
+
sendJson(response, 404, { error: "Specified key pair not found." });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
targetKey.status = body?.status || targetKey.status;
|
|
320
|
+
targetKey.status_modified_at = currentTimestamp();
|
|
321
|
+
sendNoContent(response);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (url.pathname === "/tx/v1/webhook" && method === "POST") {
|
|
325
|
+
const endpoint = url.searchParams.get("endpoint");
|
|
326
|
+
if (!endpoint) {
|
|
327
|
+
sendJson(response, 400, { error: "Missing endpoint query parameter." });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (account.webhooks.has(endpoint)) {
|
|
331
|
+
sendJson(response, 409, { error: "Webhook already enrolled." });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
account.webhooks.add(endpoint);
|
|
335
|
+
response.writeHead(201);
|
|
336
|
+
response.end();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (url.pathname === "/tx/v1/webhook" && method === "GET") {
|
|
340
|
+
sendJson(response, 200, Array.from(account.webhooks).map((webhook) => ({ webhook })));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (url.pathname === "/tx/v1/webhook" && method === "DELETE") {
|
|
344
|
+
account.webhooks.clear();
|
|
345
|
+
sendNoContent(response);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (url.pathname === "/tx/v1/webhook/public-key" && method === "GET") {
|
|
349
|
+
const id = url.searchParams.get("id") || SIMULATOR_SIGNING_KEY_ID;
|
|
350
|
+
const key = account.webhookSigningKeys.get(id) || "SIMULATOR_PUBLIC_SIGNING_KEY";
|
|
351
|
+
account.webhookSigningKeys.set(id, key);
|
|
352
|
+
sendJson(response, 200, {
|
|
353
|
+
id,
|
|
354
|
+
key
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (url.pathname === "/tx/v1/webhook/validate" && method === "POST") {
|
|
359
|
+
if (!account.webhooks.size) {
|
|
360
|
+
sendJson(response, 404, { error: "No webhooks found for the account." });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
sendJson(response, 200, {
|
|
364
|
+
all_passed: true,
|
|
365
|
+
results: Array.from(account.webhooks).map((webhook) => {
|
|
366
|
+
recordWebhookBatch(account, webhook, 1);
|
|
367
|
+
return {
|
|
368
|
+
result: "passed",
|
|
369
|
+
webhook,
|
|
370
|
+
response: {
|
|
371
|
+
body: "simulated validation ok",
|
|
372
|
+
status: 200
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
})
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (url.pathname === "/tx/v1/webhook-batch" && method === "GET") {
|
|
380
|
+
const createdAfter = url.searchParams.get("created_after");
|
|
381
|
+
const createdBefore = url.searchParams.get("created_before");
|
|
382
|
+
const statuses = getArrayQuery(url, "statuses");
|
|
383
|
+
const webhook = url.searchParams.get("webhook");
|
|
384
|
+
const limit = Number(url.searchParams.get("limit") || "500");
|
|
385
|
+
const offset = Number(url.searchParams.get("offset") || "0");
|
|
386
|
+
sendJson(response, 200, { webhook_batches: account.webhookBatches.filter((batch) => {
|
|
387
|
+
if (createdAfter && batch.created_at < createdAfter) return false;
|
|
388
|
+
if (createdBefore && batch.created_at >= createdBefore) return false;
|
|
389
|
+
if (webhook && batch.webhook !== webhook) return false;
|
|
390
|
+
if (statuses.length) {
|
|
391
|
+
if (!statuses.map((status) => `${status}_response`.replace("no_response_response", "no_response")).includes(batch.status)) return false;
|
|
392
|
+
}
|
|
393
|
+
return true;
|
|
394
|
+
}).slice(offset, offset + limit) });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const webhookBatchResendMatch = url.pathname.match(/^\/tx\/v1\/webhook-batch\/(\d+)\/resend$/);
|
|
398
|
+
if (webhookBatchResendMatch && method === "POST") {
|
|
399
|
+
const batchId = Number(webhookBatchResendMatch[1]);
|
|
400
|
+
const batch = account.webhookBatches.find((batch) => batch.batch_id === batchId);
|
|
401
|
+
if (!batch) {
|
|
402
|
+
sendJson(response, 404, { error: "webhook not found." });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
recordWebhookBatch(account, batch.webhook, batch.event_count);
|
|
406
|
+
sendJson(response, 200, {
|
|
407
|
+
batch_id: batch.batch_id,
|
|
408
|
+
customer_handle: account.customerHandle,
|
|
409
|
+
webhook: batch.webhook,
|
|
410
|
+
created_at: currentTimestamp(),
|
|
411
|
+
status_code: 200,
|
|
412
|
+
duration_in_ms: 25,
|
|
413
|
+
event_count: batch.event_count
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (url.pathname === "/tx/v1/sub-account" && method === "POST") {
|
|
418
|
+
const subAccount = createSubAccount(account, body?.company_name || "Simulator Company", body?.handle);
|
|
419
|
+
account.subAccounts.set(subAccount.handle, subAccount);
|
|
420
|
+
sendJson(response, 201, clone(subAccount));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (url.pathname === "/tx/v1/sub-account" && method === "GET") {
|
|
424
|
+
sendJson(response, 200, Array.from(account.subAccounts.values()).map((subAccount) => ({
|
|
425
|
+
company_name: subAccount.company_name,
|
|
426
|
+
enabled: subAccount.enabled,
|
|
427
|
+
handle: subAccount.handle
|
|
428
|
+
})));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const subAccountMatch = url.pathname.match(/^\/tx\/v1\/sub-account\/([^/]+)(?:\/(.+))?$/);
|
|
432
|
+
if (subAccountMatch) {
|
|
433
|
+
const [, handle, suffix = ""] = subAccountMatch;
|
|
434
|
+
const subAccount = account.subAccounts.get(handle);
|
|
435
|
+
if (!subAccount) {
|
|
436
|
+
sendJson(response, 404, { error: `Sub-account '${handle}' not found.` });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (!suffix && method === "DELETE") {
|
|
440
|
+
account.subAccounts.delete(handle);
|
|
441
|
+
sendNoContent(response);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (suffix === "suspend" && method === "POST") {
|
|
445
|
+
subAccount.enabled = false;
|
|
446
|
+
sendNoContent(response);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (suffix === "activate" && method === "POST") {
|
|
450
|
+
subAccount.enabled = true;
|
|
451
|
+
sendNoContent(response);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (suffix === "api-key" && method === "POST") {
|
|
455
|
+
const apiKeyId = subAccount.apiKeys.length ? Math.max(...subAccount.apiKeys.map((key) => key.id)) + 1 : account.nextIds.apiKey++;
|
|
456
|
+
const keyValue = createId(`subkey_${handle}`);
|
|
457
|
+
subAccount.apiKeys.push({
|
|
458
|
+
id: apiKeyId,
|
|
459
|
+
key: keyValue
|
|
460
|
+
});
|
|
461
|
+
const scope = {
|
|
462
|
+
account,
|
|
463
|
+
handle,
|
|
464
|
+
type: "sub-account"
|
|
465
|
+
};
|
|
466
|
+
apiKeyScopes.set(keyValue, scope);
|
|
467
|
+
sendJson(response, 201, {
|
|
468
|
+
id: apiKeyId,
|
|
469
|
+
key: keyValue
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (suffix === "api-key" && method === "GET") {
|
|
474
|
+
sendJson(response, 200, clone(subAccount.apiKeys));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const apiKeyItemMatch = suffix.match(/^api-key\/(\d+)$/);
|
|
478
|
+
if (apiKeyItemMatch && method === "DELETE") {
|
|
479
|
+
const apiKeyId = Number(apiKeyItemMatch[1]);
|
|
480
|
+
const deletedKey = subAccount.apiKeys.find((key) => key.id === apiKeyId);
|
|
481
|
+
subAccount.apiKeys = subAccount.apiKeys.filter((key) => key.id !== apiKeyId);
|
|
482
|
+
if (deletedKey) apiKeyScopes.delete(deletedKey.key);
|
|
483
|
+
sendNoContent(response);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (suffix === "smtp-password" && method === "POST") {
|
|
487
|
+
const smtpPassword = {
|
|
488
|
+
enabled: true,
|
|
489
|
+
id: account.nextIds.smtpPassword++,
|
|
490
|
+
smtp_password: createId(`smtp_${handle}`)
|
|
491
|
+
};
|
|
492
|
+
subAccount.smtpPasswords.push(smtpPassword);
|
|
493
|
+
sendJson(response, 201, clone(smtpPassword));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (suffix === "smtp-password" && method === "GET") {
|
|
497
|
+
sendJson(response, 200, clone(subAccount.smtpPasswords));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const smtpPasswordItemMatch = suffix.match(/^smtp-password\/(\d+)$/);
|
|
501
|
+
if (smtpPasswordItemMatch && method === "DELETE") {
|
|
502
|
+
const smtpPasswordId = Number(smtpPasswordItemMatch[1]);
|
|
503
|
+
subAccount.smtpPasswords = subAccount.smtpPasswords.filter((password) => password.id !== smtpPasswordId);
|
|
504
|
+
sendNoContent(response);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (suffix === "limit" && method === "GET") {
|
|
508
|
+
sendJson(response, 200, subAccount.limit || { sends: -1 });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (suffix === "limit" && method === "PUT") {
|
|
512
|
+
subAccount.limit = body || { sends: -1 };
|
|
513
|
+
sendNoContent(response);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (suffix === "limit" && method === "DELETE") {
|
|
517
|
+
subAccount.limit = null;
|
|
518
|
+
sendNoContent(response);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (suffix === "usage" && method === "GET") {
|
|
522
|
+
sendJson(response, 200, {
|
|
523
|
+
period_end_date: currentTimestamp(),
|
|
524
|
+
period_start_date: new Date((/* @__PURE__ */ new Date()).setDate(1)).toISOString(),
|
|
525
|
+
total_usage: subAccount.usage
|
|
526
|
+
});
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (url.pathname === "/tx/v1/metrics/engagement" && method === "GET") {
|
|
531
|
+
const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
|
|
532
|
+
sendJson(response, 200, {
|
|
533
|
+
buckets: {
|
|
534
|
+
click: createMetricsBuckets(summary.click),
|
|
535
|
+
click_tracking_delivered: createMetricsBuckets(summary.clickTrackingDelivered),
|
|
536
|
+
open: createMetricsBuckets(summary.open),
|
|
537
|
+
open_tracking_delivered: createMetricsBuckets(summary.openTrackingDelivered)
|
|
538
|
+
},
|
|
539
|
+
click: summary.click,
|
|
540
|
+
click_tracking_delivered: summary.clickTrackingDelivered,
|
|
541
|
+
end_time: url.searchParams.get("end_time") || currentTimestamp(),
|
|
542
|
+
open: summary.open,
|
|
543
|
+
open_tracking_delivered: summary.openTrackingDelivered,
|
|
544
|
+
start_time: url.searchParams.get("start_time") || (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString()
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (url.pathname === "/tx/v1/metrics/performance" && method === "GET") {
|
|
549
|
+
const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
|
|
550
|
+
sendJson(response, 200, {
|
|
551
|
+
bounced: summary.bounced,
|
|
552
|
+
buckets: {
|
|
553
|
+
bounced: createMetricsBuckets(summary.bounced),
|
|
554
|
+
delivered: createMetricsBuckets(summary.delivered),
|
|
555
|
+
processed: createMetricsBuckets(summary.processed)
|
|
556
|
+
},
|
|
557
|
+
delivered: summary.delivered,
|
|
558
|
+
end_time: url.searchParams.get("end_time") || currentTimestamp(),
|
|
559
|
+
processed: summary.processed,
|
|
560
|
+
start_time: url.searchParams.get("start_time") || (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString()
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (url.pathname === "/tx/v1/metrics/recipient-behaviour" && method === "GET") {
|
|
565
|
+
const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
|
|
566
|
+
sendJson(response, 200, {
|
|
567
|
+
buckets: {
|
|
568
|
+
unsubscribe_delivered: createMetricsBuckets(summary.unsubscribeDelivered),
|
|
569
|
+
unsubscribed: createMetricsBuckets(summary.unsubscribed)
|
|
570
|
+
},
|
|
571
|
+
end_time: url.searchParams.get("end_time") || currentTimestamp(),
|
|
572
|
+
start_time: url.searchParams.get("start_time") || (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString(),
|
|
573
|
+
unsubscribe_delivered: summary.unsubscribeDelivered,
|
|
574
|
+
unsubscribed: summary.unsubscribed
|
|
575
|
+
});
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (url.pathname === "/tx/v1/metrics/volume" && method === "GET") {
|
|
579
|
+
const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
|
|
580
|
+
sendJson(response, 200, {
|
|
581
|
+
buckets: {
|
|
582
|
+
delivered: createMetricsBuckets(summary.delivered),
|
|
583
|
+
dropped: createMetricsBuckets(summary.dropped),
|
|
584
|
+
processed: createMetricsBuckets(summary.processed)
|
|
585
|
+
},
|
|
586
|
+
delivered: summary.delivered,
|
|
587
|
+
dropped: summary.dropped,
|
|
588
|
+
end_time: url.searchParams.get("end_time") || currentTimestamp(),
|
|
589
|
+
processed: summary.processed,
|
|
590
|
+
start_time: url.searchParams.get("start_time") || (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString()
|
|
591
|
+
});
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const senderMetricsMatch = url.pathname.match(/^\/tx\/v1\/metrics\/senders\/([^/]+)$/);
|
|
595
|
+
if (senderMetricsMatch && method === "GET") {
|
|
596
|
+
const [, senderType] = senderMetricsMatch;
|
|
597
|
+
const limit = Number(url.searchParams.get("limit") || "10");
|
|
598
|
+
const offset = Number(url.searchParams.get("offset") || "0");
|
|
599
|
+
const sortOrder = url.searchParams.get("sort_order") || "desc";
|
|
600
|
+
const senders = senderType === "campaigns" ? Array.from(collectMessages(account).reduce((map, message) => {
|
|
601
|
+
const name = message.campaignId || "uncategorized";
|
|
602
|
+
const current = map.get(name) || {
|
|
603
|
+
bounced: 0,
|
|
604
|
+
delivered: 0,
|
|
605
|
+
dropped: 0,
|
|
606
|
+
name,
|
|
607
|
+
processed: 0
|
|
608
|
+
};
|
|
609
|
+
current.bounced += message.bounced;
|
|
610
|
+
current.delivered += message.delivered;
|
|
611
|
+
current.dropped += message.dropped;
|
|
612
|
+
current.processed += message.processed;
|
|
613
|
+
map.set(name, current);
|
|
614
|
+
return map;
|
|
615
|
+
}, /* @__PURE__ */ new Map()).values()) : Array.from(account.subAccounts.values()).map((subAccount) => ({
|
|
616
|
+
bounced: 0,
|
|
617
|
+
delivered: subAccount.usage,
|
|
618
|
+
dropped: 0,
|
|
619
|
+
name: subAccount.handle,
|
|
620
|
+
processed: subAccount.usage
|
|
621
|
+
}));
|
|
622
|
+
senders.sort((left, right) => {
|
|
623
|
+
return (sortOrder === "asc" ? 1 : -1) * (left.processed - right.processed);
|
|
624
|
+
});
|
|
625
|
+
sendJson(response, 200, {
|
|
626
|
+
end_time: currentTimestamp(),
|
|
627
|
+
limit,
|
|
628
|
+
offset,
|
|
629
|
+
senders: senders.slice(offset, offset + limit),
|
|
630
|
+
start_time: (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString(),
|
|
631
|
+
total: senders.length
|
|
632
|
+
});
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (url.pathname === "/tx/v1/usage" && method === "GET") {
|
|
636
|
+
sendJson(response, 200, {
|
|
637
|
+
period_end_date: currentTimestamp(),
|
|
638
|
+
period_start_date: new Date((/* @__PURE__ */ new Date()).setDate(1)).toISOString(),
|
|
639
|
+
total_usage: account.messages.length
|
|
640
|
+
});
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (url.pathname === "/tx/v1/suppression-list" && method === "POST") {
|
|
644
|
+
const entries = body?.suppression_entries || [];
|
|
645
|
+
for (const entry of entries) account.suppressionEntries.push({
|
|
646
|
+
created_at: currentTimestamp(),
|
|
647
|
+
notes: entry.notes,
|
|
648
|
+
recipient: entry.recipient,
|
|
649
|
+
sender: void 0,
|
|
650
|
+
source: "api",
|
|
651
|
+
suppression_types: entry.suppression_types || ["non-transactional"]
|
|
652
|
+
});
|
|
653
|
+
sendNoContent(response);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (url.pathname === "/tx/v1/suppression-list" && method === "GET") {
|
|
657
|
+
const recipient = url.searchParams.get("recipient");
|
|
658
|
+
const source = url.searchParams.get("source");
|
|
659
|
+
const limit = Number(url.searchParams.get("limit") || "1000");
|
|
660
|
+
const offset = Number(url.searchParams.get("offset") || "0");
|
|
661
|
+
let suppressionList = account.suppressionEntries;
|
|
662
|
+
if (recipient) suppressionList = suppressionList.filter((entry) => entry.recipient === recipient);
|
|
663
|
+
if (source) suppressionList = suppressionList.filter((entry) => entry.source === source);
|
|
664
|
+
sendJson(response, 200, { suppression_list: suppressionList.slice(offset, offset + limit) });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const suppressionDeleteMatch = url.pathname.match(/^\/tx\/v1\/suppression-list\/recipients\/([^/]+)$/);
|
|
668
|
+
if (suppressionDeleteMatch && method === "DELETE") {
|
|
669
|
+
const [, recipient] = suppressionDeleteMatch;
|
|
670
|
+
const source = url.searchParams.get("source");
|
|
671
|
+
account.suppressionEntries = account.suppressionEntries.filter((entry) => {
|
|
672
|
+
if (entry.recipient !== decodeURIComponent(recipient)) return true;
|
|
673
|
+
if (!source || source === "all") return false;
|
|
674
|
+
return entry.source !== source;
|
|
675
|
+
});
|
|
676
|
+
sendNoContent(response);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
notFound(response);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
sendText(response, 500, error instanceof Error ? error.message : "Simulator error");
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
return {
|
|
685
|
+
handler,
|
|
686
|
+
state: {
|
|
687
|
+
accounts,
|
|
688
|
+
apiKeyScopes
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
};
|
|
692
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
693
|
+
const DEFAULT_PORT = 8787;
|
|
694
|
+
const createSimulator = (options = {}) => {
|
|
695
|
+
const { host = DEFAULT_HOST, port = DEFAULT_PORT } = options;
|
|
696
|
+
const logRequests = !options.silent;
|
|
697
|
+
const emailApi = createEmailApiHandler({ logRequests });
|
|
698
|
+
const server = createServer(async (request, response) => {
|
|
699
|
+
if (new URL(request.url || "/", `http://${request.headers.host || "localhost"}`).pathname.startsWith("/tx/")) return emailApi.handler(request, response);
|
|
700
|
+
response.writeHead(404, { "content-type": "application/json" });
|
|
701
|
+
response.end(JSON.stringify({ error: "Not Found" }));
|
|
702
|
+
});
|
|
703
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
704
|
+
server.on("connection", (socket) => {
|
|
705
|
+
sockets.add(socket);
|
|
706
|
+
socket.on("close", () => {
|
|
707
|
+
sockets.delete(socket);
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
let serverUrl = null;
|
|
711
|
+
return {
|
|
712
|
+
server,
|
|
713
|
+
state: { emailApi: emailApi.state },
|
|
714
|
+
get url() {
|
|
715
|
+
return serverUrl;
|
|
716
|
+
},
|
|
717
|
+
async close() {
|
|
718
|
+
await new Promise((resolve, reject) => {
|
|
719
|
+
for (const socket of sockets) socket.destroy();
|
|
720
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
721
|
+
});
|
|
722
|
+
},
|
|
723
|
+
async listen(listenOptions = {}) {
|
|
724
|
+
const nextHost = listenOptions.host || host;
|
|
725
|
+
const nextPort = listenOptions.port ?? port;
|
|
726
|
+
await new Promise((resolve, reject) => {
|
|
727
|
+
const onError = (error) => {
|
|
728
|
+
reject(error);
|
|
729
|
+
};
|
|
730
|
+
server.once("error", onError);
|
|
731
|
+
server.listen(nextPort, nextHost, () => {
|
|
732
|
+
server.off("error", onError);
|
|
733
|
+
resolve();
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
const address = server.address();
|
|
737
|
+
serverUrl = `http://${nextHost}:${typeof address === "object" && address ? address.port : nextPort}`;
|
|
738
|
+
if (logRequests) console.info("[Simulator]", `listening on ${serverUrl}`);
|
|
739
|
+
return serverUrl;
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
};
|
|
743
|
+
export { createSimulator };
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
const LOGGER_NAME = "[MailChannels-CLI]";
|
|
4
|
+
console.info = console.info.bind(console.info, LOGGER_NAME);
|
|
5
|
+
console.error = console.error.bind(console.error, LOGGER_NAME);
|
|
6
|
+
const [command, ...args] = process.argv.slice(2);
|
|
7
|
+
switch (command) {
|
|
8
|
+
case "simulate":
|
|
9
|
+
const { createSimulator } = await import("./_chunks/simulator.mjs");
|
|
10
|
+
const { MAILCHANNELS_SIMULATOR_PORT, MAILCHANNELS_SIMULATOR_HOST } = process.env;
|
|
11
|
+
const { values } = parseArgs({
|
|
12
|
+
args,
|
|
13
|
+
options: {
|
|
14
|
+
port: {
|
|
15
|
+
type: "string",
|
|
16
|
+
short: "p",
|
|
17
|
+
default: MAILCHANNELS_SIMULATOR_PORT
|
|
18
|
+
},
|
|
19
|
+
host: {
|
|
20
|
+
type: "string",
|
|
21
|
+
short: "h",
|
|
22
|
+
default: MAILCHANNELS_SIMULATOR_HOST
|
|
23
|
+
},
|
|
24
|
+
silent: {
|
|
25
|
+
type: "boolean",
|
|
26
|
+
short: "s",
|
|
27
|
+
default: false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
const port = values.port !== void 0 ? Number.parseInt(values.port, 10) : void 0;
|
|
32
|
+
if (port !== void 0 && (isNaN(port) || port < 0 || port > 65535)) {
|
|
33
|
+
console.error("[Simulator]", `Invalid port "${values.port}": must be an integer between 0 and 65535.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const simulator = createSimulator({
|
|
37
|
+
host: values.host,
|
|
38
|
+
port,
|
|
39
|
+
silent: values.silent
|
|
40
|
+
});
|
|
41
|
+
await simulator.listen();
|
|
42
|
+
const shutdown = async () => {
|
|
43
|
+
await simulator.close();
|
|
44
|
+
process.exit(0);
|
|
45
|
+
};
|
|
46
|
+
process.on("SIGINT", shutdown);
|
|
47
|
+
process.on("SIGTERM", shutdown);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
export {};
|
package/dist/mailchannels.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { $fetch } from "ofetch";
|
|
2
2
|
import { subtle } from "node:crypto";
|
|
3
3
|
import { Buffer } from "node:buffer";
|
|
4
|
-
var version = "0.7.
|
|
4
|
+
var version = "0.7.11";
|
|
5
5
|
var MailChannelsClient = class MailChannelsClient {
|
|
6
6
|
static DEFAULT_BASE_URL = "https://api.mailchannels.net";
|
|
7
7
|
#baseUrl;
|
|
@@ -291,6 +291,7 @@ const buildSendPayload = (options) => {
|
|
|
291
291
|
};
|
|
292
292
|
};
|
|
293
293
|
var Emails = class {
|
|
294
|
+
mailchannels;
|
|
294
295
|
constructor(mailchannels) {
|
|
295
296
|
this.mailchannels = mailchannels;
|
|
296
297
|
}
|
|
@@ -635,6 +636,7 @@ async function isValidWebhook(options) {
|
|
|
635
636
|
return subtle.verify(ED25519.name, key, webhookSignatureBuffer, encoder.encode(signingString));
|
|
636
637
|
}
|
|
637
638
|
var Webhooks = class Webhooks {
|
|
639
|
+
mailchannels;
|
|
638
640
|
constructor(mailchannels) {
|
|
639
641
|
this.mailchannels = mailchannels;
|
|
640
642
|
}
|
|
@@ -864,6 +866,7 @@ var Webhooks = class Webhooks {
|
|
|
864
866
|
}
|
|
865
867
|
};
|
|
866
868
|
var SubAccounts = class SubAccounts {
|
|
869
|
+
mailchannels;
|
|
867
870
|
static COMPANY_PATTERN = /^.{3,128}$/;
|
|
868
871
|
static HANDLE_PATTERN = /^[a-z0-9]{3,128}$/;
|
|
869
872
|
constructor(mailchannels) {
|
|
@@ -1269,6 +1272,7 @@ var SubAccounts = class SubAccounts {
|
|
|
1269
1272
|
}
|
|
1270
1273
|
};
|
|
1271
1274
|
var Metrics = class {
|
|
1275
|
+
mailchannels;
|
|
1272
1276
|
constructor(mailchannels) {
|
|
1273
1277
|
this.mailchannels = mailchannels;
|
|
1274
1278
|
}
|
|
@@ -1480,6 +1484,7 @@ var Metrics = class {
|
|
|
1480
1484
|
}
|
|
1481
1485
|
};
|
|
1482
1486
|
var Suppressions = class {
|
|
1487
|
+
mailchannels;
|
|
1483
1488
|
constructor(mailchannels) {
|
|
1484
1489
|
this.mailchannels = mailchannels;
|
|
1485
1490
|
}
|
|
@@ -1571,6 +1576,7 @@ var Suppressions = class {
|
|
|
1571
1576
|
}
|
|
1572
1577
|
};
|
|
1573
1578
|
var Domains = class {
|
|
1579
|
+
mailchannels;
|
|
1574
1580
|
constructor(mailchannels) {
|
|
1575
1581
|
this.mailchannels = mailchannels;
|
|
1576
1582
|
}
|
|
@@ -1982,6 +1988,7 @@ var Domains = class {
|
|
|
1982
1988
|
}
|
|
1983
1989
|
};
|
|
1984
1990
|
var Lists = class {
|
|
1991
|
+
mailchannels;
|
|
1985
1992
|
constructor(mailchannels) {
|
|
1986
1993
|
this.mailchannels = mailchannels;
|
|
1987
1994
|
}
|
|
@@ -2070,6 +2077,7 @@ var Lists = class {
|
|
|
2070
2077
|
}
|
|
2071
2078
|
};
|
|
2072
2079
|
var Users = class {
|
|
2080
|
+
mailchannels;
|
|
2073
2081
|
constructor(mailchannels) {
|
|
2074
2082
|
this.mailchannels = mailchannels;
|
|
2075
2083
|
}
|
|
@@ -2230,6 +2238,7 @@ var Users = class {
|
|
|
2230
2238
|
}
|
|
2231
2239
|
};
|
|
2232
2240
|
var Service = class {
|
|
2241
|
+
mailchannels;
|
|
2233
2242
|
constructor(mailchannels) {
|
|
2234
2243
|
this.mailchannels = mailchannels;
|
|
2235
2244
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mailchannels-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.11",
|
|
4
4
|
"description": "Node.js SDK to integrate MailChannels API into your JavaScript or TypeScript server-side applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"types": "./dist/mailchannels.d.mts",
|
|
33
|
+
"bin": {
|
|
34
|
+
"mailchannels-sdk": "./dist/cli.mjs",
|
|
35
|
+
"mailchannels": "./dist/cli.mjs"
|
|
36
|
+
},
|
|
33
37
|
"files": [
|
|
34
38
|
"dist"
|
|
35
39
|
],
|
|
@@ -39,24 +43,23 @@
|
|
|
39
43
|
"devDependencies": {
|
|
40
44
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
41
45
|
"@types/markdown-it": "^14.1.2",
|
|
42
|
-
"@types/node": "^25.6.
|
|
46
|
+
"@types/node": "^25.6.2",
|
|
43
47
|
"@vitest/coverage-v8": "^4.1.5",
|
|
44
48
|
"changelogen": "^0.6.2",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"oxlint": "^1.62.0",
|
|
49
|
+
"obuild": "^0.4.35",
|
|
50
|
+
"oxlint": "^1.63.0",
|
|
48
51
|
"scule": "^1.3.0",
|
|
49
52
|
"typescript": "^6.0.3",
|
|
50
53
|
"vitepress": "^2.0.0-alpha.17",
|
|
51
54
|
"vitepress-plugin-group-icons": "^1.7.5",
|
|
52
|
-
"vitepress-plugin-llms": "^1.12.
|
|
55
|
+
"vitepress-plugin-llms": "^1.12.2",
|
|
53
56
|
"vitest": "^4.1.5"
|
|
54
57
|
},
|
|
55
58
|
"scripts": {
|
|
56
59
|
"build": "obuild",
|
|
57
60
|
"parity:fixtures": "node scripts/generate-parity-fixtures.mjs",
|
|
58
61
|
"release": "pnpm lint && pnpm test && pnpm build && changelogen --release && git push --follow-tags",
|
|
59
|
-
"simulate
|
|
62
|
+
"simulate": "node src/cli.ts simulate",
|
|
60
63
|
"lint": "oxlint",
|
|
61
64
|
"lint:fix": "oxlint --fix",
|
|
62
65
|
"test": "vitest run --reporter=verbose --coverage",
|
|
@@ -65,6 +68,6 @@
|
|
|
65
68
|
"docs:dev": "vitepress dev docs",
|
|
66
69
|
"docs:build": "(git fetch --unshallow -q || git fetch --all -q) && vitepress build docs",
|
|
67
70
|
"docs:preview": "vitepress preview docs",
|
|
68
|
-
"docs:snippets": "
|
|
71
|
+
"docs:snippets": "node docs/scripts/snippets.ts"
|
|
69
72
|
}
|
|
70
73
|
}
|