@plexuslabs/simmail 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/LICENSE +202 -0
- package/README.md +208 -2
- package/enums.d.ts +103 -0
- package/enums.js +863 -0
- package/index.d.ts +638 -0
- package/index.js +620 -0
- package/models.js +230 -0
- package/package.json +54 -4
package/index.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
// simmail client: wait for the email your app sends, pull out codes and
|
|
2
|
+
// links, get replies from simulated people, and drive the whole universe:
|
|
3
|
+
// mailboxes, people, cases, the clock, the World, faults, webhooks and
|
|
4
|
+
// more. No dependencies; Node 18+ (uses fetch).
|
|
5
|
+
//
|
|
6
|
+
// Objects such as a persona, a case, the World or fault rules use the API's
|
|
7
|
+
// own field names (snake_case), as the docs show them. Option arguments of
|
|
8
|
+
// the methods are camelCase.
|
|
9
|
+
|
|
10
|
+
import { Message, Mailbox, Persona, Case, Job, Account, SimEvent, Conversation, TestAddress, TagGroup } from "./models.js";
|
|
11
|
+
|
|
12
|
+
export * from "./enums.js";
|
|
13
|
+
export { Resource, Message, Mailbox, Persona, Case, Job, Account, SimEvent, Conversation, TestAddress, TagGroup } from "./models.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A failed call. status is the HTTP status and message the server's words;
|
|
17
|
+
* code (an ErrorCode), requestId, field, allowed and nearMisses are set when
|
|
18
|
+
* the server sends them, and body is the whole answer. The subclasses below
|
|
19
|
+
* say what kind of failure it is; catch SimmailError for all of them.
|
|
20
|
+
*/
|
|
21
|
+
export class SimmailError extends Error {
|
|
22
|
+
get name() { return "SimmailError"; }
|
|
23
|
+
constructor(status, message, body, requestId) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.body = body;
|
|
27
|
+
const b = body && typeof body === "object" ? body : {};
|
|
28
|
+
this.code = b.code;
|
|
29
|
+
this.requestId = b.request_id || requestId;
|
|
30
|
+
this.field = b.field;
|
|
31
|
+
this.allowed = b.allowed;
|
|
32
|
+
this.nearMisses = b.near_misses;
|
|
33
|
+
this.matched = b.matched;
|
|
34
|
+
this.waitedMs = b.waited_ms;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** 400: the request was not valid (invalid_value, invalid_regex, bad_request); field and allowed say what and what is accepted. */
|
|
38
|
+
export class ValidationError extends SimmailError {}
|
|
39
|
+
/** 401: no key, or a key that does not work. */
|
|
40
|
+
export class AuthError extends SimmailError {}
|
|
41
|
+
/** 402: your plan does not include this, or its limit is reached. */
|
|
42
|
+
export class PlanLimitError extends SimmailError {}
|
|
43
|
+
/** 403: your role or key may not do this. */
|
|
44
|
+
export class ForbiddenError extends SimmailError {}
|
|
45
|
+
/** 404: there is no such thing. */
|
|
46
|
+
export class NotFoundError extends SimmailError {}
|
|
47
|
+
/** 408: a wait ended with nothing matching; nearMisses lists what almost matched, messages what a thread had. */
|
|
48
|
+
export class WaitTimeoutError extends SimmailError {}
|
|
49
|
+
/** 409: a conflict, such as a person who decided not to reply (fast waits). */
|
|
50
|
+
export class ConflictError extends SimmailError {}
|
|
51
|
+
/** 409: expectNoEmail saw an email; email is that message. */
|
|
52
|
+
export class UnexpectedEmailError extends ConflictError {}
|
|
53
|
+
/** 422: an Idempotency-Key was used before for a different request. */
|
|
54
|
+
export class IdempotencyError extends SimmailError {}
|
|
55
|
+
/** 429: too many requests; retryAfter is in seconds, when the server says. */
|
|
56
|
+
export class RateLimitedError extends SimmailError {
|
|
57
|
+
constructor(status, message, body, requestId, retryAfter) { super(status, message, body, requestId); this.retryAfter = retryAfter; }
|
|
58
|
+
}
|
|
59
|
+
const BY_CODE = {
|
|
60
|
+
bad_request: ValidationError, invalid_value: ValidationError, invalid_regex: ValidationError, unauthorized: AuthError,
|
|
61
|
+
not_in_plan: PlanLimitError, forbidden: ForbiddenError, not_found: NotFoundError, timeout: WaitTimeoutError, wait_timeout: WaitTimeoutError,
|
|
62
|
+
conflict: ConflictError, unexpected_email: UnexpectedEmailError, idempotency_mismatch: IdempotencyError, rate_limited: RateLimitedError,
|
|
63
|
+
};
|
|
64
|
+
const BY_STATUS = { 400: ValidationError, 401: AuthError, 402: PlanLimitError, 403: ForbiddenError, 404: NotFoundError, 408: WaitTimeoutError, 409: ConflictError, 429: RateLimitedError };
|
|
65
|
+
for (const C of [ValidationError, AuthError, PlanLimitError, ForbiddenError, NotFoundError, WaitTimeoutError, ConflictError, UnexpectedEmailError, IdempotencyError, RateLimitedError]) {
|
|
66
|
+
Object.defineProperty(C.prototype, "name", { value: C.name });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The error for a failed answer: by its code, else by its status. */
|
|
70
|
+
export function errorFor(status, message, body, { retryAfter, requestId } = {}) {
|
|
71
|
+
const code = body && typeof body === "object" ? body.code : undefined;
|
|
72
|
+
const C = BY_CODE[code] || BY_STATUS[status] || SimmailError;
|
|
73
|
+
return C === RateLimitedError ? new C(status, message, body, requestId, retryAfter) : new C(status, message, body, requestId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A wait's timeout: milliseconds as a number, or a duration such as "45s".
|
|
77
|
+
const ms = v => (typeof v === "number" ? `${v}ms` : v || undefined);
|
|
78
|
+
const seg = s => encodeURIComponent(String(s));
|
|
79
|
+
const msgPath = (address, folder, uid) => `/api/mailboxes/${seg(address)}/messages/${seg(folder)}/${seg(uid)}`;
|
|
80
|
+
// Drops undefined fields, so the server keeps what was not sent.
|
|
81
|
+
const clean = o => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
82
|
+
// camelCase option names to the API's snake_case query and body names.
|
|
83
|
+
const snake = k => k.replace(/[A-Z]/g, c => "_" + c.toLowerCase());
|
|
84
|
+
const snakeKeys = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [snake(k), v]));
|
|
85
|
+
// A list answer as resource objects (anything else passes through).
|
|
86
|
+
const each = (r, f) => (Array.isArray(r) ? r.map(f) : r);
|
|
87
|
+
|
|
88
|
+
// Ids for Idempotency-Key and the like.
|
|
89
|
+
const newId = () => globalThis.crypto?.randomUUID?.() || Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
|
|
90
|
+
const sleepMs = n => new Promise(r => setTimeout(r, n));
|
|
91
|
+
// Facts about an answer, on the result without being part of its data.
|
|
92
|
+
const meta = (o, facts) => {
|
|
93
|
+
if (o && typeof o === "object") for (const [k, v] of Object.entries(facts)) if (v !== undefined) Object.defineProperty(o, k, { value: v, enumerable: false, configurable: true, writable: true });
|
|
94
|
+
return o;
|
|
95
|
+
};
|
|
96
|
+
// A Date as RFC 3339; anything else as it is.
|
|
97
|
+
const when = v => (v instanceof Date ? v.toISOString() : v);
|
|
98
|
+
// A RegExp as the server's (RE2) syntax: its source, with (?i) for the i flag.
|
|
99
|
+
const rx = v => (v instanceof RegExp ? (v.flags.includes("i") ? "(?i)" : "") + v.source : v);
|
|
100
|
+
const list = v => (Array.isArray(v) ? v.join(",") : v);
|
|
101
|
+
// header filters: ["Name:value"] or {Name: "value"}.
|
|
102
|
+
const headerList = h => (h && !Array.isArray(h) && typeof h === "object" ? Object.entries(h).map(([k, v]) => `${k}:${v}`) : h);
|
|
103
|
+
|
|
104
|
+
// The filters every mail wait and GET /api/messages understand.
|
|
105
|
+
function mailFilters({ to, tag, from, subject, contains, folder, existing, count, since, exclude, subjectRegex, bodyRegex, header, hasAttachment, attachmentName, linkDomain, ...more }) {
|
|
106
|
+
return {
|
|
107
|
+
to, tag, from, subject, contains, folder, existing, count, since: when(since), exclude: list(exclude),
|
|
108
|
+
subject_regex: rx(subjectRegex), body_regex: rx(bodyRegex), header: headerList(header),
|
|
109
|
+
has_attachment: hasAttachment, attachment_name: attachmentName, link_domain: linkDomain, ...snakeKeys(more),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class Simmail {
|
|
114
|
+
/**
|
|
115
|
+
* @param {{baseUrl?: string, apiKey?: string, universe?: string, headers?: Record<string,string>, retries?: number}} opts
|
|
116
|
+
* Defaults: SIMMAIL_URL, SIMMAIL_API_KEY and SIMMAIL_UNIVERSE from the environment.
|
|
117
|
+
* baseUrl is the server's address, such as https://c1.simmail.dev (a trailing /api is fine too).
|
|
118
|
+
* headers are sent with every request. retries (default 2) is how often a GET, or a POST with an
|
|
119
|
+
* Idempotency-Key, is tried again after a network error, 502 or 503.
|
|
120
|
+
*/
|
|
121
|
+
constructor(opts = {}) {
|
|
122
|
+
const env = (typeof process !== "undefined" && process.env) || {};
|
|
123
|
+
this.baseUrl = (opts.baseUrl || env.SIMMAIL_URL || "http://127.0.0.1:8025").replace(/\/+$/, "").replace(/\/api$/, "");
|
|
124
|
+
this.apiKey = opts.apiKey || env.SIMMAIL_API_KEY || "";
|
|
125
|
+
this.universe = opts.universe || env.SIMMAIL_UNIVERSE || "";
|
|
126
|
+
this.headers = { ...(opts.headers || {}) };
|
|
127
|
+
this.retries = opts.retries ?? 2;
|
|
128
|
+
this.#namespaces();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A client like this one that sends X-Request-Id: id with every request (the server's logs and errors carry it). */
|
|
132
|
+
withRequestId(id) {
|
|
133
|
+
return new Simmail({ baseUrl: this.baseUrl, apiKey: this.apiKey, universe: this.universe, retries: this.retries, headers: { ...this.headers, "X-Request-Id": id } });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Groups of calls by what they are about. Each group is also callable as
|
|
137
|
+
// the 0.1.0 method of the same name, so mail.clock() and
|
|
138
|
+
// mail.clock.advance("2d") both work.
|
|
139
|
+
#namespaces() {
|
|
140
|
+
const P = Simmail.prototype, call = name => (...a) => P[name].apply(this, a);
|
|
141
|
+
const ns = (fn, methods) => Object.assign(fn, methods);
|
|
142
|
+
this.mailboxes = ns(call("mailboxes"), { list: call("mailboxes"), get: address => this.mailbox(address) });
|
|
143
|
+
this.personas = ns(call("personas"), {
|
|
144
|
+
list: call("personas"), get: call("persona"), create: call("createPersona"), update: call("updatePersona"), delete: call("deletePersona"),
|
|
145
|
+
fromMail: call("personaFromMail"), temperaments: call("temperaments"),
|
|
146
|
+
/** The persona who plays an address, or null. */
|
|
147
|
+
byAddress: async address => (await P.personas.call(this)).find(p => (p.addresses || []).some(a => a.toLowerCase() === String(address).toLowerCase())) || null,
|
|
148
|
+
});
|
|
149
|
+
this.cases = ns(call("cases"), { list: call("cases"), get: call("getCase"), push: call("pushCase"), delete: call("deleteCase") });
|
|
150
|
+
this.clock = ns(call("clock"), { get: call("clock"), advance: call("advance"), freeze: call("freeze"), speed: call("speed"), live: call("live") });
|
|
151
|
+
this.jobs = ns(call("jobs"), {
|
|
152
|
+
list: async opts => (await P.jobs.call(this, opts)).jobs, run: call("runJob"), skip: call("skipJob"), bounce: call("bounceJob"), reschedule: call("rescheduleJob"),
|
|
153
|
+
runDue: call("runDue"),
|
|
154
|
+
});
|
|
155
|
+
this.world = ns(call("world"), { get: call("world"), set: call("setWorld"), sendNow: call("sendFromStream") });
|
|
156
|
+
this.faults = ns(call("faults"), { list: call("faults"), set: call("setFaults"), clear: () => P.setFaults.call(this, []), scenarios: call("faultScenarios"), add: call("addFaultScenario") });
|
|
157
|
+
this.recipients = ns(call("recipients"), { get: call("recipients"), set: call("setRecipients"), setStatus: call("setMailboxStatus") });
|
|
158
|
+
this.webhooks = ns(call("webhooks"), { list: call("webhooks"), set: call("setWebhooks"), clear: () => P.setWebhooks.call(this, []) });
|
|
159
|
+
this.insights = ns(call("insights"), { get: call("insights"), shares: call("insightShares"), share: call("shareInsights"), revoke: call("revokeInsightShare") });
|
|
160
|
+
this.events = ns(call("events"), { stream: call("events"), log: call("log"), waitFor: call("waitForEvent") });
|
|
161
|
+
this.accounts = ns(call("accounts"), { list: call("accounts"), create: call("createAccount"), update: call("updateAccount"), delete: call("deleteAccount"), test: call("testAccount") });
|
|
162
|
+
this.inspect = { health: call("health"), extract: call("extract"), schema: call("schema") };
|
|
163
|
+
this.behaviour = {
|
|
164
|
+
ai: call("ai"), setAi: call("setAi"), aiBudget: call("aiBudget"), pace: call("pace"), setPace: call("setPace"),
|
|
165
|
+
links: call("links"), setLinks: call("setLinks"), retention: call("retention"), setRetention: call("setRetention"), prune: call("pruneRetention"),
|
|
166
|
+
junk: call("junkSettings"), setJunk: call("setJunkSettings"),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#url(path, query) {
|
|
171
|
+
const url = new URL(this.baseUrl + path);
|
|
172
|
+
for (const [k, v] of Object.entries(query || {})) {
|
|
173
|
+
if (v === undefined || v === null || v === "") continue;
|
|
174
|
+
if (Array.isArray(v)) for (const x of v) url.searchParams.append(k, String(x));
|
|
175
|
+
else url.searchParams.set(k, String(v));
|
|
176
|
+
}
|
|
177
|
+
return url;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#headers(accept, body, extra) {
|
|
181
|
+
const headers = { Accept: accept, ...this.headers, ...extra };
|
|
182
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
183
|
+
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
|
|
184
|
+
if (this.universe) headers["X-Simmail-Universe"] = this.universe;
|
|
185
|
+
return headers;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// fetch, tried again after a network error, 502 or 503 when it is safe:
|
|
189
|
+
// a GET, or a write with an Idempotency-Key.
|
|
190
|
+
async #fetch(method, url, init) {
|
|
191
|
+
const safe = method === "GET" || !!(init.headers && init.headers["Idempotency-Key"]);
|
|
192
|
+
for (let n = 0; ; n++) {
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetch(url, { method, ...init });
|
|
195
|
+
if (!safe || n >= this.retries || (res.status !== 502 && res.status !== 503)) return res;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (!safe || n >= this.retries || init.signal?.aborted) throw e;
|
|
198
|
+
}
|
|
199
|
+
await sleepMs(250 * 2 ** n);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async #fail(res) {
|
|
204
|
+
const text = await res.text();
|
|
205
|
+
let data;
|
|
206
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
207
|
+
const ra = Number(res.headers.get("retry-after"));
|
|
208
|
+
const err = errorFor(res.status, (data && data.error) || res.statusText, data, {
|
|
209
|
+
retryAfter: Number.isFinite(ra) && ra > 0 ? ra : undefined, requestId: res.headers.get("x-request-id") || undefined,
|
|
210
|
+
});
|
|
211
|
+
if (err instanceof UnexpectedEmailError && data && data.message) err.email = new Message(this, data.message);
|
|
212
|
+
if (err instanceof WaitTimeoutError && data && Array.isArray(data.messages)) err.messages = data.messages.map(m => new Message(this, m));
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Any API call: the method, the path (such as "/api/clock"), a JSON body and
|
|
218
|
+
* query parameters (an array value repeats the parameter). headers adds
|
|
219
|
+
* request headers, such as an Idempotency-Key. An object answer carries
|
|
220
|
+
* requestId (and replayed, for a repeated Idempotency-Key) without them
|
|
221
|
+
* being part of its data.
|
|
222
|
+
*/
|
|
223
|
+
async request(method, path, body, query, { headers } = {}) {
|
|
224
|
+
const res = await this.#fetch(method, this.#url(path, query), { headers: this.#headers("application/json", body, headers), body: body === undefined ? undefined : JSON.stringify(body) });
|
|
225
|
+
if (!res.ok) await this.#fail(res);
|
|
226
|
+
const text = await res.text();
|
|
227
|
+
let out;
|
|
228
|
+
try { out = text ? JSON.parse(text) : null; } catch { return text; }
|
|
229
|
+
return meta(out, { requestId: res.headers.get("x-request-id") || undefined, replayed: res.headers.get("idempotent-replayed") === "true" || undefined });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Like request, for answers that are not JSON: as text, or as bytes with asBytes. */
|
|
233
|
+
async requestRaw(method, path, query, { asBytes = false } = {}) {
|
|
234
|
+
const res = await this.#fetch(method, this.#url(path, query), { headers: this.#headers("*/*") });
|
|
235
|
+
if (!res.ok) await this.#fail(res);
|
|
236
|
+
return asBytes ? new Uint8Array(await res.arrayBuffer()) : res.text();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Every setting's allowed values, defaults and limits: {version, areas}. The SDK's enums are made from it. */
|
|
240
|
+
schema() { return this.request("GET", "/api/schema"); }
|
|
241
|
+
|
|
242
|
+
// ---- who and where (https://www.simmail.dev/docs/api/accounts/#who-am-i) ----
|
|
243
|
+
|
|
244
|
+
/** Who the key is and which universe it belongs to. */
|
|
245
|
+
me() { return this.request("GET", "/api/me"); }
|
|
246
|
+
/** The universes you can open, with your role in each. */
|
|
247
|
+
universes() { return this.request("GET", "/api/universes"); }
|
|
248
|
+
/** Counts of mailboxes, messages, personas, cases and pending jobs, the clock and the writer. */
|
|
249
|
+
status() { return this.request("GET", "/api/status"); }
|
|
250
|
+
/** The server's SMTP/IMAP services, ports, security and certificate. */
|
|
251
|
+
connection() { return this.request("GET", "/api/connection"); }
|
|
252
|
+
/** The server's TLS certificate, as PEM text. */
|
|
253
|
+
connectionCert() { return this.requestRaw("GET", "/api/connection/cert"); }
|
|
254
|
+
|
|
255
|
+
// ---- waiting for mail (https://www.simmail.dev/docs/testing/wait-api/) ----
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The next email that matches. to is an address, *@domain or a list of them
|
|
259
|
+
* (or use tag, from testAddress); from, subject and contains narrow it, and
|
|
260
|
+
* so do subjectRegex/bodyRegex (RegExp or string), header ({Name: value} or
|
|
261
|
+
* ["Name:value"]), hasAttachment, attachmentName and linkDomain. since (a
|
|
262
|
+
* Date, RFC 3339 or a message id) and exclude (message ids) say which mail
|
|
263
|
+
* counts; existing: true also takes mail that came before the wait.
|
|
264
|
+
* Answers the Message (with waitedMs and matched), or with count n: {messages,
|
|
265
|
+
* count, waited_ms, matched} once n have come. On timeout it throws
|
|
266
|
+
* WaitTimeoutError with nearMisses. Unknown options pass through as snake_case.
|
|
267
|
+
*/
|
|
268
|
+
async waitForEmail({ timeout = 30000, ...filters } = {}) {
|
|
269
|
+
const r = await this.request("GET", "/api/wait", undefined, { ...mailFilters(filters), timeout: ms(timeout) });
|
|
270
|
+
if (Array.isArray(r)) return r.map(m => new Message(this, m));
|
|
271
|
+
if (r && Array.isArray(r.messages)) return meta({ ...r, messages: r.messages.map(m => new Message(this, m)) }, { requestId: r.requestId });
|
|
272
|
+
return r && typeof r === "object" ? new Message(this, r) : r;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Passes when no matching email arrives for a while: within (default "5s";
|
|
277
|
+
* a number is milliseconds), with waitForEmail's filters. Answers {none:
|
|
278
|
+
* true, waited_ms, matched}; throws UnexpectedEmailError (with .email) when one comes.
|
|
279
|
+
*/
|
|
280
|
+
expectNoEmail({ within, for: forDur, ...filters } = {}) {
|
|
281
|
+
return this.request("GET", "/api/wait/none", undefined, { ...mailFilters(filters), for: ms(within ?? forDur) });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The next email a person sends, optionally in reply to a message. fast has them write it now. Other options pass through. */
|
|
285
|
+
async waitForReply({ from, inReplyTo, fast = true, timeout = 30000, ...more }) {
|
|
286
|
+
return new Message(this, await this.request("GET", "/api/wait/reply", undefined, { from, in_reply_to: inReplyTo, fast, timeout: ms(timeout), ...snakeKeys(more) }));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The next event of a type (EventType or EventAlias such as "replied"; a
|
|
291
|
+
* list, or a prefix such as "persona.*"), optionally for one persona (id or
|
|
292
|
+
* address) or message, after an event id. fast has the persona act on what
|
|
293
|
+
* is due now. Answers the SimEvent (with waitedMs).
|
|
294
|
+
*/
|
|
295
|
+
async waitForEvent({ type, persona, messageId, after, fast, timeout = 30000 } = {}) {
|
|
296
|
+
const r = await this.request("GET", "/api/wait/event", undefined, { type: list(type), persona, message_id: messageId, after, fast, timeout: ms(timeout) });
|
|
297
|
+
return meta(new SimEvent(this, r.event), { waitedMs: r.waited_ms, requestId: r.requestId });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A conversation once nothing new has joined it for quiet (default "10s"):
|
|
302
|
+
* by messageId (any message in it) or thread (its first message's id). fast
|
|
303
|
+
* has the people in it act now. Answers a Conversation.
|
|
304
|
+
*/
|
|
305
|
+
async waitForThread({ messageId, thread, quiet, fast, timeout = 60000 } = {}) {
|
|
306
|
+
const r = await this.request("GET", "/api/wait/thread", undefined, { message_id: messageId, thread, quiet: ms(quiet), fast, timeout: ms(timeout) });
|
|
307
|
+
return new Conversation(this, r);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Codes and links from any text or HTML. */
|
|
311
|
+
extract({ text, html }) { return this.request("POST", "/api/extract", { text, html }); }
|
|
312
|
+
|
|
313
|
+
// ---- mailboxes and messages (https://www.simmail.dev/docs/api/mail/) ----
|
|
314
|
+
|
|
315
|
+
/** Every mailbox in the universe: your app's logins and the people's addresses. */
|
|
316
|
+
async mailboxes() { return each(await this.request("GET", "/api/mailboxes"), m => new Mailbox(this, m)); }
|
|
317
|
+
|
|
318
|
+
/** One mailbox, to read and wait on (no request is made). */
|
|
319
|
+
mailbox(address) { return new Mailbox(this, { address }); }
|
|
320
|
+
|
|
321
|
+
/** A mailbox's folders, with counts. */
|
|
322
|
+
folders(address) { return this.request("GET", `/api/mailboxes/${seg(address)}/folders`); }
|
|
323
|
+
|
|
324
|
+
/** A mailbox's messages, newest first (text shortened; message() has all of it). */
|
|
325
|
+
async messages(address, { folder } = {}) {
|
|
326
|
+
const r = await this.request("GET", `/api/mailboxes/${seg(address)}/messages`, undefined, { folder });
|
|
327
|
+
return each(r, m => new Message(this, m, { address, folder: folder || "INBOX" }));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** One whole message: {message, safe, case}. remote allows remote images in safe; theme "light" skips dark-mode adapting. */
|
|
331
|
+
async message(address, folder, uid, { remote, theme } = {}) {
|
|
332
|
+
const r = await this.request("GET", msgPath(address, folder, uid), undefined, { remote: remote ? 1 : undefined, theme });
|
|
333
|
+
if (r && r.message) {
|
|
334
|
+
r.message = new Message(this, r.message, { address, folder });
|
|
335
|
+
Object.defineProperty(r.message, "safe", { value: r.safe, enumerable: false });
|
|
336
|
+
Object.defineProperty(r.message, "case", { value: r.case, enumerable: false });
|
|
337
|
+
}
|
|
338
|
+
return r;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The original message, as RFC 5322 text. */
|
|
342
|
+
rawMessage(address, folder, uid) { return this.requestRaw("GET", `${msgPath(address, folder, uid)}/raw`); }
|
|
343
|
+
|
|
344
|
+
/** Attachment number n (from 0) of a message, as bytes. */
|
|
345
|
+
attachment(address, folder, uid, n) {
|
|
346
|
+
return this.requestRaw("GET", `${msgPath(address, folder, uid)}/attachments/${seg(n)}`, undefined, { asBytes: true });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** What a mail client does: mark read or unread, move to a folder. */
|
|
350
|
+
updateMessage(address, folder, uid, { seen, moveTo } = {}) {
|
|
351
|
+
return this.request("PATCH", msgPath(address, folder, uid), clean({ seen, move_to: moveTo }));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** A mailbox's conversations, most recent first. */
|
|
355
|
+
threads(address) { return this.request("GET", "/api/threads", undefined, { address }); }
|
|
356
|
+
|
|
357
|
+
/** Forces how an address behaves as a recipient: ok, unknown, domain, full, disabled or blocked. */
|
|
358
|
+
setMailboxStatus(address, status) { return this.request("PUT", `/api/mailboxes/${seg(address)}/status`, { status }); }
|
|
359
|
+
|
|
360
|
+
// ---- test addresses (https://www.simmail.dev/docs/testing/wait-api/) ----
|
|
361
|
+
|
|
362
|
+
/** A fresh address for one test: {address, tag}. domain defaults to inbox.test. */
|
|
363
|
+
createAddress({ domain, prefix, tag } = {}) { return this.request("POST", "/api/addresses", clean({ domain, prefix, tag })); }
|
|
364
|
+
|
|
365
|
+
/** A fresh address for one test, as a TestAddress: waitForEmail(), expectNoEmail(), messages(), cleanup(). */
|
|
366
|
+
async testAddress({ domain, prefix, tag } = {}) { return new TestAddress(this, await this.createAddress({ domain, prefix, tag })); }
|
|
367
|
+
|
|
368
|
+
/** Every address made with a tag, together: waitForEmail(), expectNoEmail(), messages(), cleanup(). No request is made. */
|
|
369
|
+
tag(tag) { return new TagGroup(this, tag); }
|
|
370
|
+
|
|
371
|
+
/** Messages across addresses (to: an address, *@domain or a list) or a tag's addresses, newest first. */
|
|
372
|
+
async listMessages({ tag, to, folder, limit } = {}) {
|
|
373
|
+
return each(await this.request("GET", "/api/messages", undefined, { tag, to, folder, limit }), m => new Message(this, m, { folder: folder || "INBOX" }));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Deletes the mail of those addresses (every folder, or one): {deleted, addresses}. */
|
|
377
|
+
deleteMessages({ tag, to, folder } = {}) { return this.request("DELETE", "/api/messages", undefined, { tag, to, folder }); }
|
|
378
|
+
|
|
379
|
+
// ---- sending (https://www.simmail.dev/docs/api/mail/#sending) ----
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Send an email as an account or mailbox (as the app would): from, to, cc,
|
|
383
|
+
* subject, text, html, attachments, inReplyTo, references, forward.
|
|
384
|
+
* Each call gets its own Idempotency-Key, so a retried call never sends
|
|
385
|
+
* twice; pass idempotencyKey to choose it (false sends none). The answer
|
|
386
|
+
* carries replayed (true when the server had already done it) and requestId.
|
|
387
|
+
*/
|
|
388
|
+
send(msg, { idempotencyKey } = {}) {
|
|
389
|
+
const { inReplyTo, ...rest } = msg;
|
|
390
|
+
return this.request("POST", "/api/send", inReplyTo ? { ...rest, in_reply_to: inReplyTo } : rest, undefined, idem(idempotencyKey));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Files a message straight into a mailbox, skipping SMTP; nobody is told. {to, folder, from, subject, text} or {to, folder, raw}. Idempotent like send. */
|
|
394
|
+
inject(msg, { idempotencyKey } = {}) { return this.request("POST", "/api/inject", msg, undefined, idem(idempotencyKey)); }
|
|
395
|
+
|
|
396
|
+
/** The signature people see for an address. */
|
|
397
|
+
signature(address) { return this.request("GET", `/api/accounts/${seg(address)}/signature`); }
|
|
398
|
+
/** Sets an address's signature. Admin role. */
|
|
399
|
+
setSignature(address, signature) { return this.request("PUT", `/api/accounts/${seg(address)}/signature`, { signature }); }
|
|
400
|
+
|
|
401
|
+
/** Moves a message a person got out of their Junk folder, as if they rescued it. */
|
|
402
|
+
rescue(messageId, address) { return this.request("POST", "/api/rescue", { message_id: messageId, address }); }
|
|
403
|
+
/** Has people react to a message again (one person with address). */
|
|
404
|
+
retry(messageId, address) { return this.request("POST", "/api/retry", clean({ message_id: messageId, address })); }
|
|
405
|
+
|
|
406
|
+
// ---- your app's mail logins (https://www.simmail.dev/docs/api/accounts/) ----
|
|
407
|
+
|
|
408
|
+
/** Every login, with its settings (and passwords). */
|
|
409
|
+
async accounts() { return each(await this.request("GET", "/api/accounts"), a => new Account(this, a)); }
|
|
410
|
+
/** A login your app sends and reads with over SMTP and IMAP. extra: display_name, username, allow_smtp, allow_imap, note. Admin role. */
|
|
411
|
+
createAccount(address, password, extra = {}) { return this.request("POST", "/api/accounts", { ...extra, address, password }); }
|
|
412
|
+
/** Changes a login; fields left out keep their values. Admin role. */
|
|
413
|
+
updateAccount(address, changes) { return this.request("PATCH", `/api/accounts/${seg(address)}`, changes); }
|
|
414
|
+
/** Turns a login off for good; the mailbox and its mail stay. Admin role. */
|
|
415
|
+
deleteAccount(address) { return this.request("DELETE", `/api/accounts/${seg(address)}`); }
|
|
416
|
+
/** Checks a password the way SMTP and IMAP do. proto: "smtp" or "imap" for only one. */
|
|
417
|
+
testAccount(address, { password, username, proto } = {}) {
|
|
418
|
+
return this.request("POST", `/api/accounts/${seg(address)}/test`, clean({ password, username, proto }));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- people (https://www.simmail.dev/docs/api/personas/) ----
|
|
422
|
+
|
|
423
|
+
/** Every persona, sorted by name. */
|
|
424
|
+
async personas() { return each(await this.request("GET", "/api/personas"), p => new Persona(this, p)); }
|
|
425
|
+
/** One persona. */
|
|
426
|
+
async persona(id) { return new Persona(this, await this.request("GET", `/api/personas/${seg(id)}`)); }
|
|
427
|
+
/** Creates a persona (and its mailbox). */
|
|
428
|
+
async createPersona(p) { return new Persona(this, await this.request("POST", "/api/personas", p)); }
|
|
429
|
+
/** Changes only the fields you send (a nested object you send replaces that whole object). */
|
|
430
|
+
async updatePersona(id, changes) { return new Persona(this, await this.request("PATCH", `/api/personas/${seg(id)}`, changes)); }
|
|
431
|
+
/** Deletes a persona with its memory, relations and pending jobs; the mailbox stays. */
|
|
432
|
+
deletePersona(id) { return this.request("DELETE", `/api/personas/${seg(id)}`); }
|
|
433
|
+
/** Learns a persona from an address's mail in this universe. */
|
|
434
|
+
async personaFromMail(address, { paused } = {}) {
|
|
435
|
+
const r = await this.request("POST", "/api/personas/from-mail", clean({ address, paused }));
|
|
436
|
+
if (r && r.persona) r.persona = new Persona(this, r.persona);
|
|
437
|
+
return r;
|
|
438
|
+
}
|
|
439
|
+
/** The temperaments people can have, with their odds and reply habits. */
|
|
440
|
+
temperaments() { return this.request("GET", "/api/temperaments"); }
|
|
441
|
+
|
|
442
|
+
/** A persona's memory, by case id ("" is everything outside a case). */
|
|
443
|
+
memory(id) { return this.request("GET", `/api/personas/${seg(id)}/memory`); }
|
|
444
|
+
/** Adds a note to a persona's memory. */
|
|
445
|
+
addMemory(id, { text, caseId, source }) { return this.request("POST", `/api/personas/${seg(id)}/memory`, clean({ text, case_id: caseId, source })); }
|
|
446
|
+
/** Clears a persona's memory, or only one case's. */
|
|
447
|
+
clearMemory(id, { caseId } = {}) { return this.request("DELETE", `/api/personas/${seg(id)}/memory`, undefined, { case_id: caseId }); }
|
|
448
|
+
/** Adds a note to the persona with your id for them, such as {system: "crm", id: "c-1042"}. */
|
|
449
|
+
addMemoryByExternal({ system, id, text, caseId, source }) {
|
|
450
|
+
return this.request("POST", "/api/memory/by-external", clean({ system, id, text, case_id: caseId, source }));
|
|
451
|
+
}
|
|
452
|
+
/** What a persona holds about each correspondent: {relations, stances}. */
|
|
453
|
+
relations(id) { return this.request("GET", `/api/personas/${seg(id)}/relations`); }
|
|
454
|
+
/** Has a persona forget one correspondent, or everyone. */
|
|
455
|
+
forgetRelations(id, { address } = {}) { return this.request("DELETE", `/api/personas/${seg(id)}/relations`, undefined, { address }); }
|
|
456
|
+
|
|
457
|
+
// ---- cases (https://www.simmail.dev/docs/api/cases/) ----
|
|
458
|
+
|
|
459
|
+
/** Every case, most recently updated first (stories cut short). */
|
|
460
|
+
async cases() { return each(await this.request("GET", "/api/cases"), c => new Case(this, c)); }
|
|
461
|
+
/** One case with its full story; redact: true leaves the story out. */
|
|
462
|
+
async getCase(id, { redact } = {}) { return new Case(this, await this.request("GET", `/api/cases/${seg(id)}`, undefined, { redact: redact ? "story" : undefined })); }
|
|
463
|
+
/** Push what your system knows about a matter; the people in it learn the story. */
|
|
464
|
+
async pushCase(id, kase) { return new Case(this, await this.request("PUT", `/api/cases/${seg(id)}`, kase)); }
|
|
465
|
+
/** Adds or updates people on a case. */
|
|
466
|
+
async addCasePeople(id, people) { return new Case(this, await this.request("POST", `/api/cases/${seg(id)}/people`, people)); }
|
|
467
|
+
/** Has a person start a new thread: persona (id or address), to, intent, after (such as "2h"). */
|
|
468
|
+
initiateCase(id, { persona, to, intent, after }) {
|
|
469
|
+
return this.request("POST", `/api/cases/${seg(id)}/initiate`, clean({ persona, to, intent, after }));
|
|
470
|
+
}
|
|
471
|
+
/** Writes the story and cast details again. */
|
|
472
|
+
async regenerateCase(id) { return new Case(this, await this.request("POST", `/api/cases/${seg(id)}/regenerate`, {})); }
|
|
473
|
+
/** Rewrites the answer key by hand: story, amendments, directions. */
|
|
474
|
+
async editCaseStory(id, { story, amendments, directions } = {}) {
|
|
475
|
+
return new Case(this, await this.request("PATCH", `/api/cases/${seg(id)}/story`, clean({ story, amendments, directions })));
|
|
476
|
+
}
|
|
477
|
+
/** Deletes a case, its people's memory of it and their pending work on it. */
|
|
478
|
+
deleteCase(id) { return this.request("DELETE", `/api/cases/${seg(id)}`); }
|
|
479
|
+
|
|
480
|
+
// ---- the simulated clock and jobs (https://www.simmail.dev/docs/api/clock/) ----
|
|
481
|
+
|
|
482
|
+
clock() { return this.request("GET", "/api/clock"); }
|
|
483
|
+
/** Move time on, such as "2d"; what was due happens. */
|
|
484
|
+
advance(duration) { return this.request("POST", "/api/clock/advance", { duration }); }
|
|
485
|
+
freeze() { return this.request("POST", "/api/clock/freeze", {}); }
|
|
486
|
+
speed(factor) { return this.request("POST", "/api/clock/speed", { factor }); }
|
|
487
|
+
/** Back to real time. */
|
|
488
|
+
live() { return this.request("POST", "/api/clock/live", {}); }
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Planned work: replies, follow-ups, nudges. state (JobState), persona (id or
|
|
492
|
+
* address), kind (JobKind), dueBefore (a Date, RFC 3339 or a duration such
|
|
493
|
+
* as "1h" from the simulated now) and limit narrow it. Other filters pass through.
|
|
494
|
+
*/
|
|
495
|
+
async jobs({ state, persona, kind, dueBefore, limit, ...more } = {}) {
|
|
496
|
+
const r = await this.request("GET", "/api/jobs", undefined, { state, persona, kind, due_before: when(dueBefore), limit, ...snakeKeys(more) });
|
|
497
|
+
if (r && Array.isArray(r.jobs)) r.jobs = r.jobs.map(j => new Job(this, j));
|
|
498
|
+
return r;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Runs the matching pending jobs now, in due order (persona, kind, before:
|
|
502
|
+
* only those due by then, limit): {ran: [{id, kind, persona, state, note}], count, remaining}.
|
|
503
|
+
*/
|
|
504
|
+
runDue({ persona, kind, before, limit } = {}) {
|
|
505
|
+
return this.request("POST", "/api/jobs/run-due", clean({ persona, kind, before: when(before), limit }));
|
|
506
|
+
}
|
|
507
|
+
/** Runs a pending job now. */
|
|
508
|
+
runJob(id) { return this.request("POST", `/api/jobs/${seg(id)}/run-now`, {}); }
|
|
509
|
+
/** Skips a pending job. */
|
|
510
|
+
skipJob(id) { return this.request("POST", `/api/jobs/${seg(id)}/skip`, {}); }
|
|
511
|
+
/** Turns a planned reply into a bounce and runs it now. */
|
|
512
|
+
bounceJob(id) { return this.request("POST", `/api/jobs/${seg(id)}/bounce`, {}); }
|
|
513
|
+
/** Reschedules a pending job: due (a time) or in (such as "10m" from now). */
|
|
514
|
+
rescheduleJob(id, { due, in: after } = {}) { return this.request("PATCH", `/api/jobs/${seg(id)}`, clean({ due, in: after })); }
|
|
515
|
+
|
|
516
|
+
// ---- events (https://www.simmail.dev/docs/api/events/) ----
|
|
517
|
+
|
|
518
|
+
/** Events after a point, oldest first. Page with the last id as the next since. */
|
|
519
|
+
async log({ since, limit } = {}) { return each(await this.request("GET", "/api/log", undefined, { since, limit }), e => new SimEvent(this, e)); }
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The live event stream, as an async iterator of events ({id, at, kind, data}).
|
|
523
|
+
* since replays up to 500 earlier events first. Stop with signal (an AbortSignal) or break.
|
|
524
|
+
*/
|
|
525
|
+
async *events({ since, signal } = {}) {
|
|
526
|
+
const res = await fetch(this.#url("/api/events", { since }), { headers: this.#headers("text/event-stream"), signal });
|
|
527
|
+
if (!res.ok) await this.#fail(res);
|
|
528
|
+
const decoder = new TextDecoder();
|
|
529
|
+
let buf = "";
|
|
530
|
+
try {
|
|
531
|
+
for await (const chunk of res.body) {
|
|
532
|
+
buf += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, "\n");
|
|
533
|
+
let i;
|
|
534
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
535
|
+
const block = buf.slice(0, i);
|
|
536
|
+
buf = buf.slice(i + 2);
|
|
537
|
+
const data = block.split("\n").filter(l => l.startsWith("data:")).map(l => l.slice(5).trimStart()).join("\n");
|
|
538
|
+
if (data) yield new SimEvent(this, JSON.parse(data));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
} finally {
|
|
542
|
+
res.body.cancel?.().catch?.(() => {});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---- how the universe behaves (https://www.simmail.dev/docs/api/behaviour/) ----
|
|
547
|
+
|
|
548
|
+
/** Whether people write with AI: {mode: "on" | "off"}. */
|
|
549
|
+
ai() { return this.request("GET", "/api/ai"); }
|
|
550
|
+
/** AI writing "on" or "off". Member role. */
|
|
551
|
+
setAi(mode) { return this.request("PUT", "/api/ai", { mode }); }
|
|
552
|
+
/** The server's daily AI budget, when it has one. */
|
|
553
|
+
aiBudget() { return this.request("GET", "/api/ai/budget"); }
|
|
554
|
+
/** How fast people reply. */
|
|
555
|
+
pace() { return this.request("GET", "/api/pace"); }
|
|
556
|
+
/** factor scales reply delays (0.1 is ten times faster); ignoreHours lets people reply at any hour. */
|
|
557
|
+
setPace({ factor, ignoreHours } = {}) { return this.request("PUT", "/api/pace", clean({ factor, ignore_hours: ignoreHours })); }
|
|
558
|
+
/** Recipient rules: which addresses exist, which are bad, who is behind never-seen ones. */
|
|
559
|
+
recipients() { return this.request("GET", "/api/recipients"); }
|
|
560
|
+
/** Replaces the recipient rules. Admin role. */
|
|
561
|
+
setRecipients(rules) { return this.request("PUT", "/api/recipients", rules); }
|
|
562
|
+
/** The rules for people opening links: {policy, habits, default_user_agent}. */
|
|
563
|
+
links() { return this.request("GET", "/api/links"); }
|
|
564
|
+
/** Replaces the link policy (send every field). Admin role. */
|
|
565
|
+
setLinks(policy) { return this.request("PUT", "/api/links", policy); }
|
|
566
|
+
/** How long mail and events are kept, in simulated days. */
|
|
567
|
+
retention() { return this.request("GET", "/api/retention"); }
|
|
568
|
+
/** mailDays and eventDays; 0 keeps forever. Admin role. */
|
|
569
|
+
setRetention({ mailDays, eventDays } = {}) { return this.request("PUT", "/api/retention", clean({ mail_days: mailDays, event_days: eventDays })); }
|
|
570
|
+
/** Applies retention now. */
|
|
571
|
+
pruneRetention() { return this.request("POST", "/api/retention/prune", {}); }
|
|
572
|
+
/** Spam & Junk settings: {settings, defaults}. */
|
|
573
|
+
junkSettings() { return this.request("GET", "/api/junk"); }
|
|
574
|
+
/** Replaces the Spam & Junk settings. Admin role. */
|
|
575
|
+
setJunkSettings(settings) { return this.request("PUT", "/api/junk", settings); }
|
|
576
|
+
|
|
577
|
+
// ---- chaos and faults (https://www.simmail.dev/docs/api/faults/) ----
|
|
578
|
+
|
|
579
|
+
/** The universe's fault rules, in order. */
|
|
580
|
+
faults() { return this.request("GET", "/api/faults"); }
|
|
581
|
+
/** Replaces all fault rules ([] clears them). Admin role. */
|
|
582
|
+
setFaults(rules) { return this.request("PUT", "/api/faults", rules); }
|
|
583
|
+
/** The scenario library: ready-made rules from real providers. */
|
|
584
|
+
faultScenarios() { return this.request("GET", "/api/faults/scenarios"); }
|
|
585
|
+
/** Adds a scenario as an enabled rule; match and window are optional. Admin role. */
|
|
586
|
+
addFaultScenario(id, { match, window } = {}) { return this.request("POST", `/api/faults/scenarios/${seg(id)}`, clean({ match, window })); }
|
|
587
|
+
|
|
588
|
+
// ---- inspection and insights (https://www.simmail.dev/docs/api/inspect/) ----
|
|
589
|
+
|
|
590
|
+
/** The health report of a message your app sent: spam score, links, compatibility. */
|
|
591
|
+
health(messageId) { return this.request("GET", "/api/inspect", undefined, { message_id: messageId }); }
|
|
592
|
+
/** The insights report for the last days of simulated time (1 to 366, default 30). */
|
|
593
|
+
insights({ days } = {}) { return this.request("GET", "/api/insights", undefined, { days }); }
|
|
594
|
+
/** The universe's shared reports. */
|
|
595
|
+
insightShares() { return this.request("GET", "/api/insights/shares"); }
|
|
596
|
+
/** Shares a report publicly: {token, path}. */
|
|
597
|
+
shareInsights({ title, days } = {}) { return this.request("POST", "/api/insights/shares", clean({ title, days })); }
|
|
598
|
+
/** Revokes a shared report. */
|
|
599
|
+
revokeInsightShare(token) { return this.request("DELETE", `/api/insights/shares/${seg(token)}`); }
|
|
600
|
+
|
|
601
|
+
// ---- webhooks (https://www.simmail.dev/docs/api/webhooks/) ----
|
|
602
|
+
|
|
603
|
+
/** The webhooks (secrets shown as ••••). */
|
|
604
|
+
webhooks() { return this.request("GET", "/api/webhooks"); }
|
|
605
|
+
/** Replaces all webhooks ([] removes them). Send "••••" to keep a secret. Admin role. */
|
|
606
|
+
setWebhooks(hooks) { return this.request("PUT", "/api/webhooks", hooks); }
|
|
607
|
+
|
|
608
|
+
// ---- the World (https://www.simmail.dev/docs/api/world/) ----
|
|
609
|
+
|
|
610
|
+
/** The company and its inbound streams: {world, kinds}. */
|
|
611
|
+
world() { return this.request("GET", "/api/world"); }
|
|
612
|
+
/** Replaces the company and all streams: {company, streams}. Admin role. */
|
|
613
|
+
setWorld(world) { return this.request("PUT", "/api/world", world); }
|
|
614
|
+
/** Sends one email from a stream now. */
|
|
615
|
+
sendFromStream(id) { return this.request("POST", `/api/world/streams/${seg(id)}/send`, {}); }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function idem(key) { return key === false ? undefined : { headers: { "Idempotency-Key": key || newId() } }; }
|
|
619
|
+
|
|
620
|
+
export default Simmail;
|