@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/models.js ADDED
@@ -0,0 +1,230 @@
1
+ // Resource objects. Each keeps every field the API sent, under the API's own
2
+ // names (so code written against plain answers keeps working), adds
3
+ // camelCase getters, and has methods for what you do with it next.
4
+ // .raw is the answer as it came; JSON.stringify gives the same.
5
+
6
+ const CLIENT = Symbol("simmail.client");
7
+
8
+ export class Resource {
9
+ constructor(client, data) {
10
+ Object.defineProperty(this, CLIENT, { value: client });
11
+ Object.defineProperty(this, "raw", { value: data || {} });
12
+ // Own data properties (not assignment): a field may share a getter's name.
13
+ for (const [k, v] of Object.entries(data || {})) Object.defineProperty(this, k, { value: v, enumerable: true, writable: true, configurable: true });
14
+ }
15
+ /** The client this came from. */
16
+ get client() { return this[CLIENT]; }
17
+ toJSON() { return this.raw; }
18
+ }
19
+
20
+ const re = s => (/^re:/i.test(s || "") ? s : `Re: ${s || ""}`);
21
+
22
+ /** An email in a mailbox. From waits, lists and reads. */
23
+ export class Message extends Resource {
24
+ /** @param ctx {address, folder} where it is, when the answer does not say */
25
+ constructor(client, data, ctx = {}) {
26
+ super(client, data);
27
+ if (this.address === undefined && ctx.address) Object.defineProperty(this, "address", { value: ctx.address, enumerable: false, writable: true });
28
+ if (this.folder === undefined && ctx.folder) Object.defineProperty(this, "folder", { value: ctx.folder, enumerable: false, writable: true });
29
+ }
30
+ get messageId() { return this.message_id; }
31
+ get inReplyTo() { return this.in_reply_to; }
32
+ get fromName() { return this.from_name; }
33
+ /** One-time codes found in the text (wait results; extract() for others). */
34
+ get codes() { return this.raw.codes || []; }
35
+ /** Links that verify, confirm, reset or sign in. */
36
+ get magicLinks() { return this.raw.magic_links || []; }
37
+ /** Written by a simulated person. */
38
+ get byPersona() { return !!(this.raw.by_persona || (this.raw.headers && this.raw.headers["x-simmail-simulated"])); }
39
+ get isRead() { return (this.raw.flags || []).includes("\\Seen"); }
40
+ /** How long the wait that found it took (wait results). */
41
+ get waitedMs() { return this.waited_ms; }
42
+
43
+ #where() {
44
+ if (!this.address || !this.folder || this.uid === undefined) throw new Error("this message does not say which mailbox, folder and uid it is in");
45
+ return [this.address, this.folder, this.uid];
46
+ }
47
+
48
+ /** The whole message (lists cut the text short), with .safe HTML and the .case it belongs to. */
49
+ async full(opts) { const r = await this.client.message(...this.#where(), opts); return r.message; }
50
+ /** The original RFC 5322 source. */
51
+ source() { return this.client.rawMessage(...this.#where()); }
52
+ /** Attachment number n (from 0), as bytes. */
53
+ attachment(n) { return this.client.attachment(...this.#where(), n); }
54
+ /** Codes and links in this message, for messages that do not carry them. */
55
+ extract() { return this.client.extract({ text: this.text, html: this.html }); }
56
+ markRead() { return this.client.updateMessage(...this.#where(), { seen: true }); }
57
+ markUnread() { return this.client.updateMessage(...this.#where(), { seen: false }); }
58
+ /** Moves it to another folder, as a mail client would. */
59
+ move(folder) { return this.client.updateMessage(...this.#where(), { moveTo: folder }); }
60
+
61
+ /**
62
+ * Answers this message, threaded: from the mailbox it is in (or opts.from),
63
+ * to its sender, "Re: subject". opts: text, html, cc, attachments, from.
64
+ */
65
+ reply({ from, text, html, cc, attachments } = {}) {
66
+ const refs = [...(this.raw.references || []), this.message_id].filter(Boolean);
67
+ return this.client.send({ from: from || this.address || (this.to || [])[0], to: [this.from], cc, subject: re(this.subject), text, html, attachments, inReplyTo: this.message_id, references: refs });
68
+ }
69
+ /** Waits for the reply to this message from its (first) recipient, or opts.from. */
70
+ waitForReply(opts = {}) { return this.client.waitForReply({ from: (this.to || [])[0], ...opts, inReplyTo: this.message_id }); }
71
+ /** The health report of this message, when your app sent it. */
72
+ health() { return this.client.health(this.message_id); }
73
+ /** Its conversation, once nothing new has joined it for opts.quiet (default "10s"). */
74
+ waitUntilQuiet(opts = {}) { return this.client.waitForThread({ ...opts, messageId: this.message_id }); }
75
+ }
76
+
77
+ /** A mailbox: your app's login or a person's address. */
78
+ export class Mailbox extends Resource {
79
+ get personaId() { return this.persona_id; }
80
+ get displayName() { return this.display_name; }
81
+ get canLogin() { return this.can_login; }
82
+
83
+ /** Its messages in a folder (default INBOX), newest first, as an async iterator. */
84
+ async *messages({ folder } = {}) { for (const m of await this.list({ folder })) yield m; }
85
+ /** Its messages in a folder, newest first, as an array. */
86
+ list({ folder } = {}) { return this.client.messages(this.address, { folder }); }
87
+ /** The next email to this address. Same options as waitForEmail, without to. */
88
+ waitForEmail(opts = {}) { return this.client.waitForEmail({ ...opts, to: this.address }); }
89
+ folders() { return this.client.folders(this.address); }
90
+ threads() { return this.client.threads(this.address); }
91
+ /** Forces how this address behaves as a recipient (MailboxStatus). */
92
+ setStatus(status) { return this.client.setMailboxStatus(this.address, status); }
93
+ /** Sends from this address: to, subject, text, html, cc, attachments, inReplyTo. */
94
+ send(msg, opts) { return this.client.send({ ...msg, from: this.address }, opts); }
95
+ /** Files a message straight into this mailbox, skipping SMTP. */
96
+ inject(msg, opts) { return this.client.inject({ ...msg, to: this.address }, opts); }
97
+ signature() { return this.client.signature(this.address); }
98
+ setSignature(signature) { return this.client.setSignature(this.address, signature); }
99
+ /** The persona behind this address, if any. */
100
+ async getPersona() { return this.persona_id ? this.client.persona(this.persona_id) : null; }
101
+ }
102
+
103
+ /** A simulated person. */
104
+ export class Persona extends Resource {
105
+ /** The address they write from. */
106
+ get address() { return (this.raw.addresses || [])[0]; }
107
+ get caseIds() { return this.case_ids || []; }
108
+ get externalIds() { return this.external_ids || {}; }
109
+
110
+ refresh() { return this.client.persona(this.id); }
111
+ /** Changes only what you send (a nested object you send replaces that whole object). */
112
+ update(changes) { return this.client.updatePersona(this.id, changes); }
113
+ delete() { return this.client.deletePersona(this.id); }
114
+ pause() { return this.update({ paused: true }); }
115
+ resume() { return this.update({ paused: false }); }
116
+ /** Their mailbox. */
117
+ mailbox() { return this.client.mailbox(this.address); }
118
+ /** The next email this person sends (fast: write their due reply now). */
119
+ waitForReply(opts = {}) { return this.client.waitForReply({ ...opts, from: this.address }); }
120
+ /** What they remember, by case id. */
121
+ memory() { return this.client.memory(this.id); }
122
+ /** Tells them something. */
123
+ remember(text, { caseId, source } = {}) { return this.client.addMemory(this.id, { text, caseId, source }); }
124
+ forgetMemory({ caseId } = {}) { return this.client.clearMemory(this.id, { caseId }); }
125
+ relations() { return this.client.relations(this.id); }
126
+ /** Forgets one correspondent, or everyone. */
127
+ forget(address) { return this.client.forgetRelations(this.id, { address }); }
128
+ /** Their planned work (state, kind, dueBefore, limit). */
129
+ async jobs(opts = {}) {
130
+ const r = await this.client.jobs({ ...opts, persona: this.id });
131
+ return r.jobs.filter(j => j.persona_id === undefined || j.persona_id === this.id);
132
+ }
133
+ /** Runs their pending jobs now (kind, before, limit): {ran, count, remaining}. */
134
+ runDue(opts = {}) { return this.client.runDue({ ...opts, persona: this.id }); }
135
+ /**
136
+ * Waits until they do something: an EventType or EventAlias such as
137
+ * "replied", "declined", "clicked" or "bounced" (or a list). opts: messageId,
138
+ * after, fast, timeout. Answers the SimEvent.
139
+ */
140
+ waitFor(type, opts = {}) { return this.client.waitForEvent({ ...opts, type, persona: this.id }); }
141
+ }
142
+
143
+ /** A matter your system pushed: the people in it and what is really true. */
144
+ export class Case extends Resource {
145
+ get people() { return this.raw.people || []; }
146
+ get storyBy() { return this.story_by || this.story_written_by; }
147
+ refresh({ redact } = {}) { return this.client.getCase(this.id, { redact }); }
148
+ /** Pushes more of what your system knows; it merges. */
149
+ push(update) { return this.client.pushCase(this.id, update); }
150
+ addPeople(people) { return this.client.addCasePeople(this.id, people); }
151
+ /** Has a person start a new thread: persona (id or address), to, intent, after. */
152
+ initiate(opts) { return this.client.initiateCase(this.id, opts); }
153
+ regenerate() { return this.client.regenerateCase(this.id); }
154
+ editStory(edit) { return this.client.editCaseStory(this.id, edit); }
155
+ delete() { return this.client.deleteCase(this.id); }
156
+ }
157
+
158
+ /** Something planned for later: a reply, a follow-up, a nudge. */
159
+ export class Job extends Resource {
160
+ get personaId() { return this.persona_id; }
161
+ get caseId() { return this.case_id; }
162
+ get isPending() { return this.state === "pending"; }
163
+ run() { return this.client.runJob(this.id); }
164
+ skip() { return this.client.skipJob(this.id); }
165
+ /** Turns a planned reply into a bounce, now. */
166
+ bounce() { return this.client.bounceJob(this.id); }
167
+ /** due (a time) or in (such as "10m" from now). */
168
+ reschedule(when) { return this.client.rescheduleJob(this.id, when); }
169
+ }
170
+
171
+ /** A login your app sends and reads with. */
172
+ export class Account extends Resource {
173
+ get displayName() { return this.display_name; }
174
+ get allowSmtp() { return this.allow_smtp; }
175
+ get allowImap() { return this.allow_imap; }
176
+ update(changes) { return this.client.updateAccount(this.address, changes); }
177
+ delete() { return this.client.deleteAccount(this.address); }
178
+ test(opts) { return this.client.testAccount(this.address, opts); }
179
+ mailbox() { return this.client.mailbox(this.address); }
180
+ }
181
+
182
+ /** Something that happened: {id, at, kind, data}. kind is an EventType. */
183
+ export class SimEvent extends Resource {
184
+ get personaId() { return (this.raw.data || {}).persona_id; }
185
+ get messageId() { return (this.raw.data || {}).message_id; }
186
+ get caseId() { return (this.raw.data || {}).case_id; }
187
+ /** The persona it is about, when it is about one. */
188
+ persona() { return this.personaId ? this.client.persona(this.personaId) : Promise.resolve(null); }
189
+ }
190
+
191
+ /** A conversation: its messages, oldest first. From waitForThread. */
192
+ export class Conversation extends Resource {
193
+ constructor(client, data) {
194
+ super(client, data);
195
+ this.messages = (data.messages || []).map(m => new Message(client, m));
196
+ }
197
+ /** The id of its first message. */
198
+ get root() { return this.thread; }
199
+ /** Its newest message. */
200
+ get latest() { return this.messages[this.messages.length - 1]; }
201
+ get quietMs() { return this.quiet_ms; }
202
+ get waitedMs() { return this.waited_ms; }
203
+ /** Waits again, until nothing new has joined it for opts.quiet. */
204
+ waitUntilQuiet(opts = {}) { return this.client.waitForThread({ ...opts, thread: this.thread }); }
205
+ /** Answers its newest message (Message.reply options). */
206
+ reply(opts) { return this.latest.reply(opts); }
207
+ }
208
+
209
+ /** A fresh address made for one test, from testAddress(). */
210
+ export class TestAddress extends Mailbox {
211
+ /** Passes when nothing reaches it for opts.within (default "5s"); same filters as waitForEmail. */
212
+ expectNoEmail(opts = {}) { return this.client.expectNoEmail({ ...opts, to: this.address }); }
213
+ /** Deletes its mail (every folder, or opts.folder). */
214
+ cleanup({ folder } = {}) { return this.client.deleteMessages({ to: this.address, folder }); }
215
+ toString() { return this.address; }
216
+ }
217
+
218
+ /** Every address made with one tag. From mail.tag(name). */
219
+ export class TagGroup {
220
+ constructor(client, tag) { Object.defineProperty(this, "client", { value: client }); this.tag = tag; }
221
+ /** Another address with this tag (domain, prefix). */
222
+ newAddress(opts = {}) { return this.client.testAddress({ ...opts, tag: this.tag }); }
223
+ /** The next email to any of its addresses (waitForEmail options, such as count). */
224
+ waitForEmail(opts = {}) { return this.client.waitForEmail({ ...opts, tag: this.tag }); }
225
+ expectNoEmail(opts = {}) { return this.client.expectNoEmail({ ...opts, tag: this.tag }); }
226
+ /** Their messages, newest first (folder, limit). */
227
+ messages(opts = {}) { return this.client.listMessages({ ...opts, tag: this.tag }); }
228
+ /** Deletes their mail (every folder, or opts.folder). */
229
+ cleanup({ folder } = {}) { return this.client.deleteMessages({ tag: this.tag, folder }); }
230
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@plexuslabs/simmail",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "The official simmail client: wait for the email your app sends, pull out codes and links, get replies from simulated people, and drive the whole simulated email world.",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "models.js",
17
+ "enums.js",
18
+ "enums.d.ts",
19
+ "index.d.ts",
20
+ "README.md",
21
+ "CHANGELOG.md",
22
+ "LICENSE"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test test/*.test.js"
29
+ },
30
+ "keywords": [
31
+ "email",
32
+ "testing",
33
+ "smtp",
34
+ "e2e",
35
+ "playwright",
36
+ "simmail",
37
+ "email-testing",
38
+ "magic-link",
39
+ "otp"
40
+ ],
41
+ "license": "Apache-2.0",
42
+ "author": "Plexus Labs",
43
+ "homepage": "https://www.simmail.dev/docs/",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/PlexusLabsDev/simmail-sdks.git",
47
+ "directory": "js"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/PlexusLabsDev/simmail-sdks/issues"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public",
54
+ "provenance": true
55
+ }
56
+ }