@marlinjai/mail-sdk 0.2.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/dist/index.js ADDED
@@ -0,0 +1,744 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/index.ts
22
+ var index_exports = {};
23
+ __export(index_exports, {
24
+ MailApiError: () => MailApiError,
25
+ MailNetworkError: () => MailNetworkError,
26
+ MailResponseValidationError: () => MailResponseValidationError,
27
+ MailTimeoutError: () => MailTimeoutError,
28
+ createDashboardMailClient: () => createDashboardMailClient,
29
+ createMailClient: () => createMailClient,
30
+ dispositionFilename: () => dispositionFilename,
31
+ generateIdempotencyKey: () => generateIdempotencyKey
32
+ });
33
+ module.exports = __toCommonJS(index_exports);
34
+
35
+ // src/client.ts
36
+ var import_mail_contract2 = require("@marlinjai/mail-contract");
37
+
38
+ // src/core.ts
39
+ var import_mail_contract = require("@marlinjai/mail-contract");
40
+
41
+ // src/errors.ts
42
+ var MailApiError = class extends Error {
43
+ code;
44
+ status;
45
+ details;
46
+ requestId;
47
+ constructor(input) {
48
+ super(input.message);
49
+ this.name = "MailApiError";
50
+ this.code = input.code;
51
+ this.status = input.status;
52
+ this.details = input.details;
53
+ this.requestId = input.requestId ?? null;
54
+ }
55
+ };
56
+ var MailNetworkError = class extends Error {
57
+ cause;
58
+ constructor(message, cause) {
59
+ super(message);
60
+ this.name = "MailNetworkError";
61
+ this.cause = cause;
62
+ }
63
+ };
64
+ var MailTimeoutError = class extends Error {
65
+ timeoutMs;
66
+ constructor(timeoutMs) {
67
+ super(`mail service request timed out after ${timeoutMs}ms`);
68
+ this.name = "MailTimeoutError";
69
+ this.timeoutMs = timeoutMs;
70
+ }
71
+ };
72
+ var MailResponseValidationError = class extends Error {
73
+ operationId;
74
+ issues;
75
+ constructor(operationId, issues) {
76
+ super(`response for "${operationId}" did not match the mail-contract schema`);
77
+ this.name = "MailResponseValidationError";
78
+ this.operationId = operationId;
79
+ this.issues = issues;
80
+ }
81
+ };
82
+
83
+ // src/runtime.ts
84
+ function webCrypto() {
85
+ const c = globalThis.crypto;
86
+ if (!c?.randomUUID) {
87
+ throw new Error(
88
+ "globalThis.crypto.randomUUID is not available in this runtime (Node 18 needs --experimental-global-webcrypto or Node 19+); @marlinjai/mail-sdk needs Web Crypto to mint idempotency keys"
89
+ );
90
+ }
91
+ return c;
92
+ }
93
+ function generateIdempotencyKey() {
94
+ return webCrypto().randomUUID();
95
+ }
96
+ function defaultSleep(ms, signal) {
97
+ return new Promise((resolve, reject) => {
98
+ if (signal?.aborted) {
99
+ reject(signal.reason);
100
+ return;
101
+ }
102
+ const timer = setTimeout(resolve, ms);
103
+ signal?.addEventListener(
104
+ "abort",
105
+ () => {
106
+ clearTimeout(timer);
107
+ reject(signal.reason);
108
+ },
109
+ { once: true }
110
+ );
111
+ });
112
+ }
113
+ function backoffDelayMs(attempt, baseMs, maxDelayMs, random = Math.random) {
114
+ const exp = Math.min(maxDelayMs, baseMs * 2 ** attempt);
115
+ return Math.floor(random() * exp);
116
+ }
117
+ var MAX_RETRY_DELAY_MS = 6e4;
118
+ function parseRetryAfterMs(header, now = Date.now) {
119
+ if (!header) return null;
120
+ const trimmed = header.trim();
121
+ if (/^\d+$/.test(trimmed)) {
122
+ return Math.min(MAX_RETRY_DELAY_MS, Number(trimmed) * 1e3);
123
+ }
124
+ const at = Date.parse(trimmed);
125
+ if (Number.isNaN(at)) return null;
126
+ const delta = at - now();
127
+ if (delta <= 0) return 0;
128
+ return Math.min(MAX_RETRY_DELAY_MS, delta);
129
+ }
130
+
131
+ // src/core.ts
132
+ function responseMeta(response, requestId) {
133
+ return {
134
+ status: response.status,
135
+ requestId,
136
+ headers: response.headers,
137
+ usageWarnings: (0, import_mail_contract.parseUsageWarningHeader)(response.headers.get(import_mail_contract.USAGE_WARNING_HEADER))
138
+ };
139
+ }
140
+ var RETRY_BASE_MS = 250;
141
+ var RETRY_MAX_MS = 8e3;
142
+ var MAX_ERROR_TEXT_LENGTH = 2e3;
143
+ function toQueryString(query) {
144
+ if (!query) return "";
145
+ const params = new URLSearchParams();
146
+ for (const [key, value] of Object.entries(query)) {
147
+ if (value === void 0 || value === null) continue;
148
+ params.set(key, String(value));
149
+ }
150
+ const s = params.toString();
151
+ return s.length > 0 ? `?${s}` : "";
152
+ }
153
+ function isCallerAbort(err, callerSignal) {
154
+ return callerSignal?.aborted === true && err instanceof Error && err.name === "AbortError";
155
+ }
156
+ async function parseErrorBody(status, text) {
157
+ if (text.length > 0) {
158
+ try {
159
+ const json = JSON.parse(text);
160
+ const parsed = import_mail_contract.ErrorBody.safeParse(json);
161
+ if (parsed.success) return parsed.data.error;
162
+ } catch {
163
+ }
164
+ }
165
+ const raw = text.slice(0, MAX_ERROR_TEXT_LENGTH);
166
+ const code = status >= 500 ? "internal_error" : "invalid_request";
167
+ return {
168
+ code,
169
+ message: `mail service returned ${status} with an unparseable body`,
170
+ details: raw.length > 0 ? { raw } : void 0
171
+ };
172
+ }
173
+ async function send(config, operationId, args, opts, accept) {
174
+ const route = import_mail_contract.routes[operationId];
175
+ const sleep = config.sleep ?? defaultSleep;
176
+ const path = (0, import_mail_contract.buildPath)(route.path, args.params ?? {});
177
+ const url = `${config.baseUrl.replace(/\/+$/, "")}${path}${toQueryString(args.query)}`;
178
+ const hasJsonBody = route.body !== void 0;
179
+ const bodyText = hasJsonBody ? JSON.stringify(args.body ?? {}) : void 0;
180
+ const idempotencyKey = (0, import_mail_contract.acceptsIdempotencyKey)(route) ? opts.idempotencyKey ?? generateIdempotencyKey() : void 0;
181
+ let lastError;
182
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
183
+ const timeoutController = new AbortController();
184
+ const timeout = setTimeout(() => timeoutController.abort(), config.timeoutMs);
185
+ const onCallerAbort = () => timeoutController.abort(opts.signal?.reason);
186
+ opts.signal?.addEventListener("abort", onCallerAbort, { once: true });
187
+ try {
188
+ const headers = {
189
+ ...config.authHeaders(),
190
+ accept
191
+ };
192
+ if (hasJsonBody) headers["content-type"] = "application/json";
193
+ if (idempotencyKey) headers[import_mail_contract.IDEMPOTENCY_KEY_HEADER] = idempotencyKey;
194
+ if (config.userAgent) headers["user-agent"] = config.userAgent;
195
+ let response;
196
+ try {
197
+ response = await config.fetch(url, {
198
+ method: route.method,
199
+ headers,
200
+ body: bodyText,
201
+ signal: timeoutController.signal
202
+ });
203
+ } catch (err) {
204
+ if (isCallerAbort(err, opts.signal)) throw err;
205
+ if (err instanceof Error && err.name === "AbortError") {
206
+ lastError = new MailTimeoutError(config.timeoutMs);
207
+ } else {
208
+ lastError = new MailNetworkError("mail service request failed before a response was received", err);
209
+ }
210
+ if (attempt < config.maxRetries) {
211
+ await sleep(backoffDelayMs(attempt, RETRY_BASE_MS, RETRY_MAX_MS, config.random), opts.signal);
212
+ continue;
213
+ }
214
+ throw lastError;
215
+ }
216
+ const requestId = response.headers.get(import_mail_contract.REQUEST_ID_HEADER);
217
+ if (response.status >= 200 && response.status < 300) return { response, requestId };
218
+ const text = await response.text();
219
+ const errorBody = await parseErrorBody(response.status, text);
220
+ const apiError = new MailApiError({
221
+ code: errorBody.code,
222
+ status: response.status,
223
+ message: errorBody.message,
224
+ details: errorBody.details,
225
+ requestId
226
+ });
227
+ lastError = apiError;
228
+ const retryable = (0, import_mail_contract.isRetryableError)(errorBody.code, errorBody.details);
229
+ if (retryable && attempt < config.maxRetries) {
230
+ const retryAfterMs = parseRetryAfterMs(response.headers.get(import_mail_contract.RETRY_AFTER_HEADER));
231
+ const delay = retryAfterMs ?? backoffDelayMs(attempt, RETRY_BASE_MS, RETRY_MAX_MS, config.random);
232
+ await sleep(delay, opts.signal);
233
+ continue;
234
+ }
235
+ throw apiError;
236
+ } finally {
237
+ clearTimeout(timeout);
238
+ opts.signal?.removeEventListener("abort", onCallerAbort);
239
+ }
240
+ }
241
+ throw lastError instanceof Error ? lastError : new Error("mail service request failed");
242
+ }
243
+ async function execute(config, operationId, args = {}, opts = {}) {
244
+ const route = import_mail_contract.routes[operationId];
245
+ if (route.responseType === "text") {
246
+ const file = await executeFile(config, operationId, args, opts);
247
+ return file.content;
248
+ }
249
+ const { response, requestId } = await send(config, operationId, args, opts, "application/json");
250
+ const text = await response.text();
251
+ const json = text.length > 0 ? JSON.parse(text) : void 0;
252
+ let data;
253
+ if (!config.validateResponses) data = json;
254
+ else {
255
+ const parsed = route.response.safeParse(json);
256
+ if (!parsed.success) throw new MailResponseValidationError(operationId, parsed.error.issues);
257
+ data = parsed.data;
258
+ }
259
+ opts.onResponse?.(responseMeta(response, requestId));
260
+ return data;
261
+ }
262
+ function dispositionFilename(value) {
263
+ if (!value) return null;
264
+ const extended = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(value);
265
+ if (extended) {
266
+ try {
267
+ return decodeURIComponent(extended[1].trim());
268
+ } catch {
269
+ }
270
+ }
271
+ const plain = /filename\s*=\s*"([^"]*)"|filename\s*=\s*([^;\s]+)/i.exec(value);
272
+ return plain ? plain[1] ?? plain[2] ?? null : null;
273
+ }
274
+ async function executeFile(config, operationId, args = {}, opts = {}) {
275
+ const route = import_mail_contract.routes[operationId];
276
+ if (route.responseType !== "text") throw new Error(`"${operationId}" answers with JSON: use execute`);
277
+ const { response, requestId } = await send(config, operationId, args, opts, "text/html, text/plain, */*");
278
+ const content = await response.text();
279
+ const warnings = (0, import_mail_contract.parseExportWarningsHeader)(response.headers.get(import_mail_contract.EXPORT_WARNINGS_HEADER));
280
+ const count = Number(response.headers.get(import_mail_contract.EXPORT_WARNING_COUNT_HEADER));
281
+ const file = {
282
+ content,
283
+ contentType: response.headers.get("content-type") ?? "application/octet-stream",
284
+ filename: dispositionFilename(response.headers.get("content-disposition")) ?? "export",
285
+ warnings,
286
+ warningCount: Number.isInteger(count) && count >= warnings.length ? count : warnings.length,
287
+ requestId
288
+ };
289
+ opts.onResponse?.(responseMeta(response, requestId));
290
+ return file;
291
+ }
292
+ async function executeMultipart(config, operationId, args, opts = {}) {
293
+ const route = import_mail_contract.routes[operationId];
294
+ if (!route.multipart) throw new Error(`"${operationId}" is not a multipart route`);
295
+ const sleep = config.sleep ?? defaultSleep;
296
+ const path = (0, import_mail_contract.buildPath)(route.path, args.params ?? {});
297
+ const url = `${config.baseUrl.replace(/\/+$/, "")}${path}`;
298
+ const idempotencyKey = (0, import_mail_contract.acceptsIdempotencyKey)(route) ? opts.idempotencyKey ?? generateIdempotencyKey() : void 0;
299
+ let lastError;
300
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
301
+ const timeoutController = new AbortController();
302
+ const timeout = setTimeout(() => timeoutController.abort(), config.timeoutMs);
303
+ const onCallerAbort = () => timeoutController.abort(opts.signal?.reason);
304
+ opts.signal?.addEventListener("abort", onCallerAbort, { once: true });
305
+ try {
306
+ const form = new FormData();
307
+ form.set(route.multipart.fileField, args.file, args.filename);
308
+ if (route.multipart.jsonField) {
309
+ form.set(route.multipart.jsonField, JSON.stringify(args.json ?? {}));
310
+ }
311
+ const headers = { ...config.authHeaders(), accept: "application/json" };
312
+ if (idempotencyKey) headers[import_mail_contract.IDEMPOTENCY_KEY_HEADER] = idempotencyKey;
313
+ if (config.userAgent) headers["user-agent"] = config.userAgent;
314
+ let response;
315
+ try {
316
+ response = await config.fetch(url, { method: route.method, headers, body: form, signal: timeoutController.signal });
317
+ } catch (err) {
318
+ if (isCallerAbort(err, opts.signal)) throw err;
319
+ if (err instanceof Error && err.name === "AbortError") {
320
+ lastError = new MailTimeoutError(config.timeoutMs);
321
+ } else {
322
+ lastError = new MailNetworkError("mail service request failed before a response was received", err);
323
+ }
324
+ if (attempt < config.maxRetries) {
325
+ await sleep(backoffDelayMs(attempt, RETRY_BASE_MS, RETRY_MAX_MS, config.random), opts.signal);
326
+ continue;
327
+ }
328
+ throw lastError;
329
+ }
330
+ const requestId = response.headers.get(import_mail_contract.REQUEST_ID_HEADER);
331
+ if (response.status >= 200 && response.status < 300) {
332
+ const text2 = await response.text();
333
+ const json = text2.length > 0 ? JSON.parse(text2) : void 0;
334
+ let data;
335
+ if (!config.validateResponses) data = json;
336
+ else {
337
+ const parsed = route.response.safeParse(json);
338
+ if (!parsed.success) throw new MailResponseValidationError(operationId, parsed.error.issues);
339
+ data = parsed.data;
340
+ }
341
+ opts.onResponse?.(responseMeta(response, requestId));
342
+ return data;
343
+ }
344
+ const text = await response.text();
345
+ const errorBody = await parseErrorBody(response.status, text);
346
+ const apiError = new MailApiError({
347
+ code: errorBody.code,
348
+ status: response.status,
349
+ message: errorBody.message,
350
+ details: errorBody.details,
351
+ requestId
352
+ });
353
+ lastError = apiError;
354
+ const retryable = (0, import_mail_contract.isRetryableError)(errorBody.code, errorBody.details);
355
+ if (retryable && attempt < config.maxRetries) {
356
+ const retryAfterMs = parseRetryAfterMs(response.headers.get(import_mail_contract.RETRY_AFTER_HEADER));
357
+ const delay = retryAfterMs ?? backoffDelayMs(attempt, RETRY_BASE_MS, RETRY_MAX_MS, config.random);
358
+ await sleep(delay, opts.signal);
359
+ continue;
360
+ }
361
+ throw apiError;
362
+ } finally {
363
+ clearTimeout(timeout);
364
+ opts.signal?.removeEventListener("abort", onCallerAbort);
365
+ }
366
+ }
367
+ throw lastError instanceof Error ? lastError : new Error("mail service request failed");
368
+ }
369
+ function buildAuthorizationHeader(token) {
370
+ return { [import_mail_contract.AUTHORIZATION_HEADER]: `${import_mail_contract.BEARER_PREFIX}${token}` };
371
+ }
372
+
373
+ // src/namespaces.ts
374
+ function createNamespaces(config) {
375
+ return {
376
+ /**
377
+ * `workspaces` (plural) is the dashboard-only pair for a person with no
378
+ * workspace context yet: creating one, and listing the ones they belong to.
379
+ * `workspace` (singular, below) is the ordinary per-workspace read/update,
380
+ * reachable with a workspace API key or a dashboard call already bound to one.
381
+ */
382
+ workspaces: {
383
+ create: (body, opts) => execute(config, "workspaces.create", { body }, opts),
384
+ list: (query, opts) => execute(config, "workspaces.list", { query }, opts)
385
+ },
386
+ workspace: {
387
+ get: (opts) => execute(config, "workspace.get", {}, opts),
388
+ update: (body, opts) => execute(config, "workspace.update", { body }, opts),
389
+ /**
390
+ * Hands the workspace to another auth-brain company (owner only, and a
391
+ * workspace API key is refused outright). What changes is whose erasure
392
+ * takes the workspace with it, nothing about billing or who may sign in.
393
+ */
394
+ move: (body, opts) => execute(config, "workspace.move", { body }, opts)
395
+ },
396
+ members: {
397
+ list: (query, opts) => execute(config, "members.list", { query }, opts),
398
+ /**
399
+ * The service binds people by auth-brain subject only (it never sees a
400
+ * login), so the caller resolves the person first and sends the subject
401
+ * with their email and name.
402
+ */
403
+ add: (body, opts) => execute(config, "members.add", { body }, opts),
404
+ update: (id, body, opts) => execute(config, "members.update", { params: { id }, body }, opts),
405
+ remove: (id, opts) => execute(config, "members.remove", { params: { id } }, opts)
406
+ },
407
+ /**
408
+ * Invitations: an admin invites an address with a role (the token is in
409
+ * the create response only); the invited person accepts through the
410
+ * dashboard (`accept` is dashboard-only and names the signed-in person).
411
+ */
412
+ invites: {
413
+ create: (body, opts) => execute(config, "invites.create", { body }, opts),
414
+ list: (query, opts) => execute(config, "invites.list", { query }, opts),
415
+ revoke: (id, opts) => execute(config, "invites.revoke", { params: { id } }, opts),
416
+ accept: (body, opts) => execute(config, "invites.accept", { body }, opts)
417
+ },
418
+ apiKeys: {
419
+ list: (query, opts) => execute(config, "apiKeys.list", { query }, opts),
420
+ create: (body, opts) => execute(config, "apiKeys.create", { body }, opts),
421
+ revoke: (id, opts) => execute(config, "apiKeys.revoke", { params: { id } }, opts)
422
+ },
423
+ auditLog: {
424
+ list: (query, opts) => execute(config, "audit.list", { query }, opts)
425
+ },
426
+ templates: {
427
+ list: (query, opts) => execute(config, "templates.list", { query }, opts),
428
+ create: (body, opts) => execute(config, "templates.create", { body }, opts),
429
+ get: (id, opts) => execute(config, "templates.get", { params: { id } }, opts),
430
+ update: (id, body, opts) => execute(config, "templates.update", { params: { id }, body }, opts),
431
+ delete: (id, opts) => execute(config, "templates.delete", { params: { id } }, opts),
432
+ versions: (id, query, opts) => execute(config, "templates.versions", { params: { id }, query }, opts),
433
+ version: (id, version, opts) => execute(config, "templates.version", { params: { id, version } }, opts),
434
+ compile: (id, body = {}, opts) => execute(config, "templates.compile", { params: { id }, body }, opts),
435
+ /**
436
+ * Creates a template (version 1) from MJML. What the editor cannot hold
437
+ * as a block is kept as compiled HTML and reported in `warnings`; broken
438
+ * MJML is `invalid_mjml` with the line and column in `details`.
439
+ */
440
+ import: (body, opts) => execute(config, "templates.import", { body }, opts),
441
+ /** What `import` would create, compiled, without saving anything. */
442
+ importPreview: (body, opts) => execute(config, "templates.importPreview", { body }, opts),
443
+ /**
444
+ * The template (or a past `version`) as an `.mjml` or `.html` file, asset
445
+ * addresses absolute. Never refused for the asset policy: see `warnings`.
446
+ */
447
+ export: (id, query, opts) => executeFile(config, "templates.export", { params: { id }, query }, opts)
448
+ },
449
+ /**
450
+ * Saved sections: one section of a document kept under a name, so a footer
451
+ * built once can be dropped into any other template of the workspace.
452
+ *
453
+ * A stamp, not a live partial: inserting copies, and `update` overwriting
454
+ * the saved copy leaves every template that already holds one untouched.
455
+ * A workspace keeps at most `MAX_SAVED_SECTIONS` of them, and a name is
456
+ * unique within it, so a duplicate is `already_exists` rather than a
457
+ * silent overwrite.
458
+ */
459
+ savedSections: {
460
+ list: (query, opts) => execute(config, "savedSections.list", { query }, opts),
461
+ create: (body, opts) => execute(config, "savedSections.create", { body }, opts),
462
+ update: (id, body, opts) => execute(config, "savedSections.update", { params: { id }, body }, opts),
463
+ delete: (id, opts) => execute(config, "savedSections.delete", { params: { id } }, opts)
464
+ },
465
+ /** Compiles an unsaved document (the editor's live preview), not a saved template. */
466
+ compile: (body, opts) => execute(config, "compile", { body }, opts),
467
+ assets: {
468
+ /** `file` must be a `Blob` (edge-safe): wrap a Node `Buffer` with `new Blob([buffer])`. */
469
+ upload: (file, filename, opts) => executeMultipart(config, "assets.upload", { file, filename }, opts),
470
+ /**
471
+ * Copies a remote image into the workspace's assets and answers with the
472
+ * new asset, whose `url` is the service's own. The workspace setting
473
+ * `asset_policy: 'service_only'` accepts only such addresses in a mail.
474
+ */
475
+ import: (body, opts) => execute(config, "assets.import", { body }, opts),
476
+ get: (id, opts) => execute(config, "assets.get", { params: { id } }, opts)
477
+ },
478
+ providers: {
479
+ list: (query, opts) => execute(config, "providers.list", { query }, opts),
480
+ create: (body, opts) => execute(config, "providers.create", { body }, opts),
481
+ get: (id, opts) => execute(config, "providers.get", { params: { id } }, opts),
482
+ update: (id, body, opts) => execute(config, "providers.update", { params: { id }, body }, opts),
483
+ delete: (id, opts) => execute(config, "providers.delete", { params: { id } }, opts),
484
+ verify: (id, opts) => execute(config, "providers.verify", { params: { id } }, opts),
485
+ usage: (id, opts) => execute(config, "providers.usage", { params: { id } }, opts),
486
+ /** Clears the provider's bounce anomaly, reopening bounce blocking, test sends and mailing starts. */
487
+ clearAnomaly: (id, opts) => execute(config, "providers.clearAnomaly", { params: { id } }, opts),
488
+ /** Stores the signing secret of a Resend webhook registered by hand, so bounces and complaints are accepted. */
489
+ setEventsSecret: (id, body, opts) => execute(config, "providers.setEventsSecret", { params: { id }, body }, opts)
490
+ },
491
+ topics: {
492
+ list: (query, opts) => execute(config, "topics.list", { query }, opts),
493
+ create: (body, opts) => execute(config, "topics.create", { body }, opts),
494
+ update: (id, body, opts) => execute(config, "topics.update", { params: { id }, body }, opts)
495
+ },
496
+ contacts: {
497
+ upsert: (body, opts) => execute(config, "contacts.upsert", { body }, opts),
498
+ list: (query, opts) => execute(config, "contacts.list", { query }, opts),
499
+ get: (id, opts) => execute(config, "contacts.get", { params: { id } }, opts),
500
+ erase: (id, opts) => execute(config, "contacts.erase", { params: { id } }, opts),
501
+ messages: (id, query, opts) => execute(config, "contacts.messages", { params: { id }, query }, opts),
502
+ /** Every automation this person has been through, for the contact's own page. */
503
+ automations: (id, query, opts) => execute(config, "contacts.automations", { params: { id }, query }, opts)
504
+ },
505
+ suppressions: {
506
+ list: (query, opts) => execute(config, "suppressions.list", { query }, opts),
507
+ create: (body, opts) => execute(config, "suppressions.create", { body }, opts),
508
+ delete: (id, opts) => execute(config, "suppressions.delete", { params: { id } }, opts)
509
+ },
510
+ mailings: {
511
+ list: (query, opts) => execute(config, "mailings.list", { query }, opts),
512
+ create: (body, opts) => execute(config, "mailings.create", { body }, opts),
513
+ get: (id, opts) => execute(config, "mailings.get", { params: { id } }, opts),
514
+ /** The mailing's content snapshot as an `.mjml` or `.html` file, like `templates.export`. */
515
+ export: (id, query, opts) => executeFile(config, "mailings.export", { params: { id }, query }, opts),
516
+ update: (id, body, opts) => execute(config, "mailings.update", { params: { id }, body }, opts),
517
+ addRecipients: (id, body, opts) => execute(config, "mailings.addRecipients", { params: { id }, body }, opts),
518
+ listRecipients: (id, query, opts) => execute(config, "mailings.listRecipients", { params: { id }, query }, opts),
519
+ test: (id, body, opts) => execute(config, "mailings.test", { params: { id }, body }, opts),
520
+ send: (id, opts) => execute(config, "mailings.send", { params: { id }, body: {} }, opts),
521
+ pause: (id, opts) => execute(config, "mailings.pause", { params: { id }, body: {} }, opts),
522
+ resume: (id, opts) => execute(config, "mailings.resume", { params: { id }, body: {} }, opts),
523
+ cancel: (id, opts) => execute(config, "mailings.cancel", { params: { id }, body: {} }, opts),
524
+ retryFailed: (id, body = {}, opts) => execute(config, "mailings.retryFailed", { params: { id }, body }, opts),
525
+ duplicate: (id, opts) => execute(config, "mailings.duplicate", { params: { id }, body: {} }, opts),
526
+ // S4: scheduling, segments and A/B testing
527
+ addSegment: (id, body, opts) => execute(config, "mailings.addSegment", { params: { id }, body }, opts),
528
+ schedule: (id, body, opts) => execute(config, "mailings.schedule", { params: { id }, body }, opts),
529
+ unschedule: (id, opts) => execute(config, "mailings.unschedule", { params: { id }, body: {} }, opts),
530
+ setAbTest: (id, body, opts) => execute(config, "mailings.setAbTest", { params: { id }, body }, opts),
531
+ clearAbTest: (id, opts) => execute(config, "mailings.clearAbTest", { params: { id } }, opts),
532
+ pickAbWinner: (id, body, opts) => execute(config, "mailings.pickAbWinner", { params: { id }, body }, opts),
533
+ analytics: (id, opts) => execute(config, "mailings.analytics", { params: { id } }, opts)
534
+ },
535
+ messages: {
536
+ list: (query, opts) => execute(config, "messages.list", { query }, opts),
537
+ get: (id, opts) => execute(config, "messages.get", { params: { id } }, opts)
538
+ },
539
+ webhooks: {
540
+ list: (query, opts) => execute(config, "webhooks.list", { query }, opts),
541
+ create: (body, opts) => execute(config, "webhooks.create", { body }, opts),
542
+ get: (id, opts) => execute(config, "webhooks.get", { params: { id } }, opts),
543
+ update: (id, body, opts) => execute(config, "webhooks.update", { params: { id }, body }, opts),
544
+ delete: (id, opts) => execute(config, "webhooks.delete", { params: { id } }, opts),
545
+ rotateSecret: (id, opts) => execute(config, "webhooks.rotateSecret", { params: { id } }, opts),
546
+ deliveries: (id, query, opts) => execute(config, "webhooks.deliveries", { params: { id }, query }, opts),
547
+ redeliver: (id, deliveryId, opts) => execute(config, "webhooks.redeliver", { params: { id, delivery_id: deliveryId } }, opts)
548
+ },
549
+ // S4: the platform features.
550
+ tags: {
551
+ list: (query, opts) => execute(config, "tags.list", { query }, opts),
552
+ create: (body, opts) => execute(config, "tags.create", { body }, opts),
553
+ delete: (id, opts) => execute(config, "tags.delete", { params: { id } }, opts),
554
+ assign: (id, body, opts) => execute(config, "tags.assign", { params: { id }, body }, opts),
555
+ unassign: (id, body, opts) => execute(config, "tags.unassign", { params: { id }, body }, opts)
556
+ },
557
+ segments: {
558
+ list: (query, opts) => execute(config, "segments.list", { query }, opts),
559
+ create: (body, opts) => execute(config, "segments.create", { body }, opts),
560
+ /** Counts what a filter matches, without saving it. */
561
+ preview: (body, opts) => execute(config, "segments.preview", { body }, opts),
562
+ get: (id, opts) => execute(config, "segments.get", { params: { id } }, opts),
563
+ update: (id, body, opts) => execute(config, "segments.update", { params: { id }, body }, opts),
564
+ delete: (id, opts) => execute(config, "segments.delete", { params: { id } }, opts)
565
+ },
566
+ signupForms: {
567
+ list: (query, opts) => execute(config, "signupForms.list", { query }, opts),
568
+ create: (body, opts) => execute(config, "signupForms.create", { body }, opts),
569
+ get: (id, opts) => execute(config, "signupForms.get", { params: { id } }, opts),
570
+ embed: (id, opts) => execute(config, "signupForms.embed", { params: { id } }, opts),
571
+ update: (id, body, opts) => execute(config, "signupForms.update", { params: { id }, body }, opts),
572
+ delete: (id, opts) => execute(config, "signupForms.delete", { params: { id } }, opts),
573
+ /** The public submission route: usable without a workspace key in a browser form handler. */
574
+ submit: (id, body, opts) => execute(config, "signupForms.submit", { params: { id }, body }, opts)
575
+ },
576
+ imports: {
577
+ /** Step 1: upload the CSV. The answer carries its columns, a sample and a suggested mapping. */
578
+ create: (file, filename, opts) => executeMultipart(config, "imports.create", { file, filename }, opts),
579
+ list: (query, opts) => execute(config, "imports.list", { query }, opts),
580
+ get: (id, opts) => execute(config, "imports.get", { params: { id } }, opts),
581
+ /** Step 2: set (or revise) the mapping; the dry run starts, and any earlier one is discarded. */
582
+ setMapping: (id, body, opts) => execute(config, "imports.setMapping", { params: { id }, body }, opts),
583
+ /** Step 3: commit the dry run of `mapping_version`. */
584
+ commit: (id, body, opts) => execute(config, "imports.commit", { params: { id }, body }, opts),
585
+ cancel: (id, opts) => execute(config, "imports.cancel", { params: { id }, body: {} }, opts),
586
+ rows: (id, query, opts) => execute(config, "imports.rows", { params: { id }, query }, opts)
587
+ },
588
+ tracking: {
589
+ get: (opts) => execute(config, "tracking.get", {}, opts),
590
+ update: (body, opts) => execute(config, "tracking.update", { body }, opts)
591
+ },
592
+ contactProperties: {
593
+ list: (opts) => execute(config, "contactProperties.list", {}, opts),
594
+ create: (body, opts) => execute(config, "contactProperties.create", { body }, opts),
595
+ delete: (key, opts) => execute(config, "contactProperties.delete", { params: { key } }, opts)
596
+ },
597
+ // S5: billing.
598
+ billing: {
599
+ plans: (opts) => execute(config, "billing.plans", {}, opts),
600
+ subscription: (opts) => execute(config, "billing.subscription", {}, opts),
601
+ usage: (opts) => execute(config, "billing.usage", {}, opts),
602
+ checkout: (body, opts) => execute(config, "billing.checkout", { body }, opts),
603
+ portal: (body, opts) => execute(config, "billing.portal", { body }, opts)
604
+ },
605
+ // A1: automations.
606
+ automations: {
607
+ list: (query, opts) => execute(config, "automations.list", { query }, opts),
608
+ create: (body, opts) => execute(config, "automations.create", { body }, opts),
609
+ get: (id, opts) => execute(config, "automations.get", { params: { id } }, opts),
610
+ /** Saves the draft. `base_version` is the draft version you loaded; a stale one is `conflict` (409). */
611
+ update: (id, body, opts) => execute(config, "automations.update", { params: { id }, body }, opts),
612
+ delete: (id, opts) => execute(config, "automations.delete", { params: { id } }, opts),
613
+ /** Every problem that stops the draft from being published, with nothing saved. */
614
+ validate: (id, body, opts) => execute(config, "automations.validate", { params: { id }, body: body ?? {} }, opts),
615
+ /**
616
+ * Publishes the draft. On a live automation the first call answers
617
+ * `published: false` with the people who would have to move; call again
618
+ * with `confirm: true` to go ahead.
619
+ */
620
+ publish: (id, body, opts) => execute(config, "automations.publish", { params: { id }, body: body ?? {} }, opts),
621
+ pause: (id, opts) => execute(config, "automations.pause", { params: { id }, body: {} }, opts),
622
+ resume: (id, opts) => execute(config, "automations.resume", { params: { id }, body: {} }, opts),
623
+ archive: (id, opts) => execute(config, "automations.archive", { params: { id }, body: {} }, opts),
624
+ duplicate: (id, body, opts) => execute(config, "automations.duplicate", { params: { id }, body: body ?? {} }, opts),
625
+ /** Enters people by hand; each answer says entered, or refused with the reason. */
626
+ enroll: (id, body, opts) => execute(config, "automations.enroll", { params: { id }, body }, opts),
627
+ testEmail: (id, stepId, body, opts) => execute(config, "automations.testEmail", { params: { id, step_id: stepId }, body }, opts),
628
+ runs: (id, query, opts) => execute(config, "automations.runs", { params: { id }, query }, opts),
629
+ /** One person's journey, step by step. */
630
+ run: (id, runId, opts) => execute(config, "automations.run", { params: { id, run_id: runId } }, opts),
631
+ exitRun: (id, runId, body, opts) => execute(config, "automations.exitRun", { params: { id, run_id: runId }, body: body ?? {} }, opts),
632
+ report: (id, opts) => execute(config, "automations.report", { params: { id } }, opts)
633
+ },
634
+ events: {
635
+ /** Tells the service something happened, so the automations waiting for it enter this person. */
636
+ create: (body, opts) => execute(config, "events.create", { body }, opts),
637
+ list: (query, opts) => execute(config, "events.list", { query }, opts)
638
+ }
639
+ };
640
+ }
641
+
642
+ // src/pagination.ts
643
+ async function* paginate(config, operationId, args = {}, opts = {}) {
644
+ let cursor = args.query?.cursor;
645
+ const seenCursors = /* @__PURE__ */ new Set();
646
+ for (; ; ) {
647
+ const query = { ...args.query, cursor };
648
+ const result = await execute(config, operationId, { params: args.params, query }, opts);
649
+ const page = result;
650
+ for (const item of page.data) yield item;
651
+ if (page.next_cursor === null) return;
652
+ if (seenCursors.has(page.next_cursor)) {
653
+ throw new Error(`mail service returned a repeated pagination cursor for "${operationId}"`);
654
+ }
655
+ seenCursors.add(page.next_cursor);
656
+ cursor = page.next_cursor;
657
+ }
658
+ }
659
+
660
+ // src/client.ts
661
+ var DEFAULT_TIMEOUT_MS = 1e4;
662
+ var DEFAULT_MAX_RETRIES = 3;
663
+ function resolveFetch(provided) {
664
+ const impl = provided ?? (typeof fetch === "function" ? fetch : void 0);
665
+ if (!impl) {
666
+ throw new Error(
667
+ "@marlinjai/mail-sdk needs a `fetch` implementation: none was given and none is global in this runtime (Node 18+ and every edge runtime provide one; pass `fetch` explicitly otherwise)"
668
+ );
669
+ }
670
+ return impl.bind(globalThis);
671
+ }
672
+ function buildConfig(options, authHeaders) {
673
+ if (options.baseUrl.trim().length === 0) throw new Error("createMailClient requires a non-empty baseUrl");
674
+ return {
675
+ baseUrl: options.baseUrl,
676
+ authHeaders,
677
+ fetch: resolveFetch(options.fetch),
678
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
679
+ maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
680
+ userAgent: options.userAgent,
681
+ validateResponses: options.validateResponses ?? true,
682
+ sleep: options.__sleep,
683
+ random: options.__random
684
+ };
685
+ }
686
+ async function checkHealth(config, opts) {
687
+ try {
688
+ const response = await config.fetch(`${config.baseUrl.replace(/\/+$/, "")}${import_mail_contract2.HEALTH_PATH}`, {
689
+ method: "GET",
690
+ signal: opts?.signal
691
+ });
692
+ return response.ok;
693
+ } catch {
694
+ return false;
695
+ }
696
+ }
697
+ function buildClient(config) {
698
+ return {
699
+ ...createNamespaces(config),
700
+ request: (operationId, args, opts) => execute(config, operationId, args ?? {}, opts),
701
+ paginate: (operationId, args, opts) => paginate(config, operationId, args ?? {}, opts),
702
+ health: (opts) => checkHealth(config, opts)
703
+ };
704
+ }
705
+ function createMailClient(options) {
706
+ const config = buildConfig(options, () => buildAuthorizationHeader(options.apiKey));
707
+ return buildClient(config);
708
+ }
709
+ function assertServerSide() {
710
+ const hasWindow = typeof globalThis === "object" && "window" in globalThis && globalThis.window !== void 0;
711
+ if (hasWindow) {
712
+ throw new Error(
713
+ "createDashboardMailClient must never run in a browser: its service token authenticates as the whole dashboard, not one signed-in person, and a browser bundle would ship it to every visitor. Call it only from server-side code (a Next.js server action, route handler or server component) and pass the signed-in person to forUser() per request."
714
+ );
715
+ }
716
+ }
717
+ function createDashboardMailClient(options) {
718
+ assertServerSide();
719
+ return {
720
+ forUser(user) {
721
+ const config = buildConfig(options, () => ({
722
+ ...buildAuthorizationHeader(options.serviceToken),
723
+ [import_mail_contract2.SUBJECT_HEADER]: user.subject,
724
+ ...user.workspaceId ? { [import_mail_contract2.WORKSPACE_HEADER]: user.workspaceId } : {}
725
+ }));
726
+ return buildClient(config);
727
+ }
728
+ };
729
+ }
730
+
731
+ // src/index.ts
732
+ __reExport(index_exports, require("@marlinjai/mail-contract"), module.exports);
733
+ // Annotate the CommonJS export names for ESM import in node:
734
+ 0 && (module.exports = {
735
+ MailApiError,
736
+ MailNetworkError,
737
+ MailResponseValidationError,
738
+ MailTimeoutError,
739
+ createDashboardMailClient,
740
+ createMailClient,
741
+ dispositionFilename,
742
+ generateIdempotencyKey,
743
+ ...require("@marlinjai/mail-contract")
744
+ });