mailchannels-sdk 0.7.9 → 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.
@@ -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 };