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