@civitas-cerebrum/email-client 0.0.7 → 0.0.9

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/README.md CHANGED
@@ -183,12 +183,46 @@ const allEmails = await client.receiveAll({
183
183
  |---|---|---|---|
184
184
  | `filters` | `EmailFilter[]` | — | **Required.** Array of filters (AND logic) |
185
185
  | `folder` | `string` | `'INBOX'` | IMAP folder to search. Accepts a literal path or a `specialUse` role (e.g. `'\\Sent'`, `'\\Trash'`) |
186
+ | `folders` | `string[]` | — | Several folders to search at once. Takes precedence over `folder`. See [Searching multiple folders](#searching-multiple-folders) |
186
187
  | `waitTimeout` | `number` | `30000` | Max milliseconds to poll before throwing an error |
187
188
  | `pollInterval` | `number` | `3000` | Milliseconds to wait between IMAP fetch attempts |
188
189
  | `expectedCount` | `number` | `1` | Number of matching emails required before returning |
189
190
  | `downloadDir` | `string` | `os.tmpdir()` | Directory to save downloaded `.html` copies |
190
191
  | `maxFetchLimit` | `number` | `50` | Max emails to fetch per polling cycle (memory protection) |
191
192
 
193
+ #### Searching multiple folders
194
+
195
+ A message does not always arrive where you expect it. Mail from a new or
196
+ low-reputation sending domain is routinely filed as spam by the receiving
197
+ server, and a spam-filed message is never found by an inbox-only search — no
198
+ matter how long you wait for it.
199
+
200
+ Pass `folders` to search several mailboxes in one call. Every folder is searched
201
+ on every polling cycle and the matches are pooled, so the wait ends as soon as
202
+ the message shows up anywhere in the set:
203
+
204
+ ```ts
205
+ const email = await client.receive({
206
+ filters: [{ type: EmailFilterType.SUBJECT, value: 'Your verification code' }],
207
+ folders: ['INBOX', '[Gmail]/Spam'], // or ['INBOX', '\\Junk'] to stay portable
208
+ waitTimeout: 60000,
209
+ });
210
+ ```
211
+
212
+ `folders` is accepted by `receive`, `receiveAll`, `clean` and `mark`, and takes
213
+ precedence over `folder` when both are given. For `clean` and `mark` the action
214
+ is applied in each folder in turn and the returned count is the total.
215
+
216
+ **Missing folders.** A single folder is strict — naming one that does not exist
217
+ throws, listing the folders the server does have. A folder *list* is tolerant:
218
+ an absent folder is skipped so a portable set works against servers that lack
219
+ one of them. If none of the listed folders can be opened, the call still throws.
220
+
221
+ > **Do not reach for `\All` as a shortcut.** All Mail also contains sent,
222
+ > archived and recently-deleted copies, so a search across it will keep finding
223
+ > a message you have already moved or deleted. Name the folders you actually
224
+ > mean.
225
+
192
226
  #### Available Filters (`EmailFilterType`)
193
227
 
194
228
  | Type | Value Type | Description |
@@ -240,10 +274,24 @@ await client.clean({
240
274
  filters: [{ type: EmailFilterType.FROM, value: 'noreply@example.com' }],
241
275
  });
242
276
 
243
- // Nuke the entire inbox (Use with caution!)
244
- await client.clean();
277
+ // Delete across several folders in one call
278
+ await client.clean({
279
+ filters: [{ type: EmailFilterType.SUBJECT, value: 'my-test-run-42' }],
280
+ folders: ['INBOX', '[Gmail]/Spam'],
281
+ });
282
+
283
+ // Nuke an entire folder (Use with caution!)
284
+ await client.clean(); // every message in INBOX
285
+ await client.clean({ folder: 'scratch' }); // every message in "scratch"
245
286
  ```
246
287
 
288
+ > **Sharing a mailbox?** `clean()` with no filters deletes everything in the
289
+ > target folder, including messages another process put there. If more than one
290
+ > test run can touch the same mailbox, stamp a unique per-run token into every
291
+ > subject you send and always pass a filter selecting it — then a cleanup can
292
+ > only ever remove its own mail. Reserve the unfiltered form for a folder your
293
+ > run created and owns.
294
+
247
295
  #### Folder Resolution
248
296
 
249
297
  Any `folder` or `archiveFolder` option accepts either a **literal path** (e.g. `'[Gmail]/Trash'`) or a **`specialUse` role** prefixed with `\`. The client resolves roles to the correct server path at runtime using IMAP LIST metadata, so your code works regardless of the mail server's language or naming conventions.
@@ -1,4 +1,4 @@
1
- import { EmailSendOptions, EmailReceiveOptions, EmailCredentials, ReceivedEmail, EmailFilter, EmailMarkOptions, EmailClientConfig } from './types.js';
1
+ import { EmailSendOptions, EmailReceiveOptions, EmailCredentials, ReceivedEmail, EmailFilter, EmailMarkOptions, EmailClientConfig, EmailCleanOptions } from './types.js';
2
2
  /**
3
3
  * A comprehensive client for handling SMTP sending and IMAP polling/management.
4
4
  * Provides unified APIs for interacting with mail servers using robust polling mechanisms.
@@ -40,14 +40,11 @@ export declare class EmailClient {
40
40
  receiveAll(options: EmailReceiveOptions): Promise<ReceivedEmail[]>;
41
41
  /**
42
42
  * Deletes emails from the mailbox based on the provided filters.
43
- * If no filters are provided, it deletes ALL emails in the specified folder.
44
- * @param options Filtering criteria and the target folder.
43
+ * If no filters are provided, it deletes ALL emails in the specified folder(s).
44
+ * @param options Filtering criteria and the target folder(s).
45
45
  * @returns The number of emails successfully deleted.
46
46
  */
47
- clean(options?: {
48
- filters?: EmailFilter[];
49
- folder?: string;
50
- }): Promise<number>;
47
+ clean(options?: EmailCleanOptions): Promise<number>;
51
48
  /**
52
49
  * Modifies the state or flags of emails in the mailbox (e.g., Read, Unread, Flagged, Archived).
53
50
  * @param options Action details, filtering criteria, and target folder configuration.
@@ -84,6 +81,22 @@ export declare class EmailClient {
84
81
  * Safely wraps IMAP connection, UID searching, custom action execution, and logout.
85
82
  */
86
83
  private _executeImapAction;
84
+ /**
85
+ * Resolves a requested folder list to the subset that actually exists on the server.
86
+ *
87
+ * A single requested folder is strict — a missing folder throws the diagnostic
88
+ * "Failed to open folder" error, which is what a caller naming one folder wants.
89
+ * A multi-folder request is tolerant: an absent folder is skipped with a log line
90
+ * so a portable set (e.g. `['INBOX', '\\Junk']`) works on servers that lack one of
91
+ * them. If nothing in the list can be opened, that is still an error.
92
+ */
93
+ private _openableFolders;
94
+ /**
95
+ * Collapses the `folder` / `folders` option pair into the folder list to operate on.
96
+ * `folders` wins when both are supplied; the default remains a lone 'INBOX', so
97
+ * every existing single-folder caller keeps its exact behaviour.
98
+ */
99
+ private normalizeFolders;
87
100
  /**
88
101
  * Helper to scrape mailbox paths for better error reporting
89
102
  */
@@ -118,13 +118,13 @@ export class EmailClient {
118
118
  // ════════════════════════════════════════════════════════════════════════
119
119
  /**
120
120
  * Deletes emails from the mailbox based on the provided filters.
121
- * If no filters are provided, it deletes ALL emails in the specified folder.
122
- * @param options Filtering criteria and the target folder.
121
+ * If no filters are provided, it deletes ALL emails in the specified folder(s).
122
+ * @param options Filtering criteria and the target folder(s).
123
123
  * @returns The number of emails successfully deleted.
124
124
  */
125
125
  async clean(options) {
126
- const folder = options?.folder ?? 'INBOX';
127
- return this._executeImapAction(folder, options?.filters, 'delete', async (client, uids) => {
126
+ const folders = this.normalizeFolders(options?.folder, options?.folders);
127
+ return this._executeImapAction(folders, options?.filters, 'delete', async (client, uids, folder) => {
128
128
  await client.messageDelete(uids, { uid: true });
129
129
  log('Deleted %d email(s) from "%s"', uids.length, folder);
130
130
  });
@@ -135,12 +135,13 @@ export class EmailClient {
135
135
  * @returns The number of emails successfully modified.
136
136
  */
137
137
  async mark(options) {
138
- const { action, filters, folder = 'INBOX', archiveFolder = 'Archive' } = options;
138
+ const { action, filters, folder, folders: folderList, archiveFolder = 'Archive' } = options;
139
139
  const isValidAction = Array.isArray(action) || Object.values(EmailMarkAction).includes(action);
140
140
  if (!isValidAction) {
141
141
  throw new Error(`Unsupported mark action: ${action}`);
142
142
  }
143
- return this._executeImapAction(folder, filters, 'mark', async (client, uids) => {
143
+ const folders = this.normalizeFolders(folder, folderList);
144
+ return this._executeImapAction(folders, filters, 'mark', async (client, uids, folder) => {
144
145
  if (uids.length === 0)
145
146
  return;
146
147
  // Handle Custom Flags
@@ -225,21 +226,27 @@ export class EmailClient {
225
226
  * Manages IMAP connection, continuous retry logic, timeout enforcement, and cleanup.
226
227
  */
227
228
  async _pollMailbox(options, returnAll) {
228
- const { filters, folder = 'INBOX', waitTimeout = 30000, pollInterval = 3000, downloadDir, expectedCount = 1, maxFetchLimit = 50 } = options;
229
+ const { filters, folder, folders: folderList, waitTimeout = 30000, pollInterval = 3000, downloadDir, expectedCount = 1, maxFetchLimit = 50 } = options;
229
230
  this.validateFilters(filters);
231
+ const requestedFolders = this.normalizeFolders(folder, folderList);
230
232
  const deadline = Date.now() + waitTimeout;
231
233
  const client = this.createImapClient();
234
+ // UIDs are scoped to a mailbox, not to the account, so the same UID can name
235
+ // a different message in each searched folder. Key the seen-set by folder.
232
236
  const seenUids = new Set();
233
237
  const accumulatedMatches = []; // Track across polling cycles
234
238
  try {
235
239
  await client.connect();
236
240
  this.logImapConnection();
237
- const resolvedFolder = await this._resolveFolder(client, folder);
241
+ const openableFolders = await this._openableFolders(client, requestedFolders);
242
+ const searchedIn = openableFolders.join(', ');
238
243
  while (Date.now() < deadline) {
239
- await client.mailboxOpen(resolvedFolder);
240
- const candidates = await this.fetchNewCandidates(client, filters, seenUids, downloadDir, maxFetchLimit);
241
- const newMatches = this.applyFilters(candidates, filters);
242
- accumulatedMatches.push(...newMatches);
244
+ for (const resolvedFolder of openableFolders) {
245
+ await client.mailboxOpen(resolvedFolder);
246
+ const candidates = await this.fetchNewCandidates(client, resolvedFolder, filters, seenUids, downloadDir, maxFetchLimit);
247
+ const newMatches = this.applyFilters(candidates, filters);
248
+ accumulatedMatches.push(...newMatches);
249
+ }
243
250
  if (accumulatedMatches.length >= expectedCount) {
244
251
  if (returnAll) {
245
252
  return accumulatedMatches;
@@ -256,7 +263,7 @@ export class EmailClient {
256
263
  }
257
264
  await new Promise(resolve => setTimeout(resolve, pollInterval));
258
265
  }
259
- throw new Error(`Found ${accumulatedMatches.length}/${expectedCount} emails within ${waitTimeout}ms. Searched in "${resolvedFolder}" for: ${this.formatFilterSummary(filters)}`);
266
+ throw new Error(`Found ${accumulatedMatches.length}/${expectedCount} emails within ${waitTimeout}ms. Searched in "${searchedIn}" for: ${this.formatFilterSummary(filters)}`);
260
267
  }
261
268
  finally {
262
269
  try {
@@ -271,44 +278,37 @@ export class EmailClient {
271
278
  * Core execution engine used by `clean()` and `mark()`.
272
279
  * Safely wraps IMAP connection, UID searching, custom action execution, and logout.
273
280
  */
274
- async _executeImapAction(folder, filters, actionName, actionFn) {
281
+ async _executeImapAction(folders, filters, actionName, actionFn) {
275
282
  const client = this.createImapClient();
276
283
  try {
277
284
  await client.connect();
278
285
  this.logImapConnection();
279
- const resolvedFolder = await this._resolveFolder(client, folder);
280
- try {
286
+ const openableFolders = await this._openableFolders(client, folders);
287
+ let affected = 0;
288
+ for (const resolvedFolder of openableFolders) {
281
289
  await client.mailboxOpen(resolvedFolder);
282
- }
283
- catch (err) {
284
- if (err.serverResponseCode === 'NONEXISTENT' || err.message.includes('Unknown Mailbox')) {
285
- const available = await this._listAvailableFolders(client);
286
- throw new Error(`Failed to open folder "${resolvedFolder}".\n` +
287
- `Available folders on this server: [${available.join(', ')}]\n` +
288
- `Check your ARCHIVE_FOLDER or folder settings.`);
290
+ const searchCriteria = filters && filters.length > 0
291
+ ? this.buildSearchCriteria(filters)
292
+ : { all: true };
293
+ const uids = await client.search(searchCriteria, { uid: true });
294
+ if (!uids || uids.length === 0) {
295
+ log('No emails to %s in "%s"', actionName, resolvedFolder);
296
+ continue;
289
297
  }
290
- throw err;
291
- }
292
- const searchCriteria = filters && filters.length > 0
293
- ? this.buildSearchCriteria(filters)
294
- : { all: true };
295
- const uids = await client.search(searchCriteria, { uid: true });
296
- if (!uids || uids.length === 0) {
297
- log('No emails to %s in "%s"', actionName, folder);
298
- return 0;
299
- }
300
- try {
301
- await actionFn(client, uids);
302
- }
303
- catch (err) {
304
- if (err.message.includes('NONEXISTENT') || err.message.includes('Unknown Mailbox')) {
305
- const available = await this._listAvailableFolders(client);
306
- throw new Error(`Action "${actionName}" failed. A target folder (${folder}) does not exist.\n` +
307
- `Available folders: [${available.join(', ')}]`);
298
+ try {
299
+ await actionFn(client, uids, resolvedFolder);
308
300
  }
309
- throw err;
301
+ catch (err) {
302
+ if (err.message.includes('NONEXISTENT') || err.message.includes('Unknown Mailbox')) {
303
+ const available = await this._listAvailableFolders(client);
304
+ throw new Error(`Action "${actionName}" failed. A target folder (${resolvedFolder}) does not exist.\n` +
305
+ `Available folders: [${available.join(', ')}]`);
306
+ }
307
+ throw err;
308
+ }
309
+ affected += uids.length;
310
310
  }
311
- return uids.length;
311
+ return affected;
312
312
  }
313
313
  finally {
314
314
  try {
@@ -319,6 +319,64 @@ export class EmailClient {
319
319
  }
320
320
  }
321
321
  }
322
+ /**
323
+ * Resolves a requested folder list to the subset that actually exists on the server.
324
+ *
325
+ * A single requested folder is strict — a missing folder throws the diagnostic
326
+ * "Failed to open folder" error, which is what a caller naming one folder wants.
327
+ * A multi-folder request is tolerant: an absent folder is skipped with a log line
328
+ * so a portable set (e.g. `['INBOX', '\\Junk']`) works on servers that lack one of
329
+ * them. If nothing in the list can be opened, that is still an error.
330
+ */
331
+ async _openableFolders(client, folders) {
332
+ const strict = folders.length === 1;
333
+ const openable = [];
334
+ for (const folder of folders) {
335
+ let resolved;
336
+ try {
337
+ resolved = await this._resolveFolder(client, folder);
338
+ }
339
+ catch (err) {
340
+ if (strict)
341
+ throw err;
342
+ log('Skipping folder "%s" — it could not be resolved on this server: %o', folder, err);
343
+ continue;
344
+ }
345
+ try {
346
+ await client.mailboxOpen(resolved);
347
+ openable.push(resolved);
348
+ }
349
+ catch (err) {
350
+ const missing = err.serverResponseCode === 'NONEXISTENT' || String(err.message).includes('Unknown Mailbox');
351
+ if (!missing)
352
+ throw err;
353
+ if (strict) {
354
+ const available = await this._listAvailableFolders(client);
355
+ throw new Error(`Failed to open folder "${resolved}".\n` +
356
+ `Available folders on this server: [${available.join(', ')}]\n` +
357
+ `Check your ARCHIVE_FOLDER or folder settings.`);
358
+ }
359
+ log('Skipping folder "%s" — it does not exist on this server', resolved);
360
+ }
361
+ }
362
+ if (openable.length === 0) {
363
+ const available = await this._listAvailableFolders(client);
364
+ throw new Error(`Failed to open folder "${folders.join(', ')}".\n` +
365
+ `Available folders on this server: [${available.join(', ')}]\n` +
366
+ `Check your ARCHIVE_FOLDER or folder settings.`);
367
+ }
368
+ return openable;
369
+ }
370
+ /**
371
+ * Collapses the `folder` / `folders` option pair into the folder list to operate on.
372
+ * `folders` wins when both are supplied; the default remains a lone 'INBOX', so
373
+ * every existing single-folder caller keeps its exact behaviour.
374
+ */
375
+ normalizeFolders(folder, folders) {
376
+ if (folders && folders.length > 0)
377
+ return [...folders];
378
+ return [folder ?? 'INBOX'];
379
+ }
322
380
  /**
323
381
  * Helper to scrape mailbox paths for better error reporting
324
382
  */
@@ -423,12 +481,13 @@ export class EmailClient {
423
481
  return criteria;
424
482
  }
425
483
  /** Fetches unread/unseen messages from IMAP that match the search criteria. */
426
- async fetchNewCandidates(client, filters, seenUids, downloadDir, maxFetchLimit = 50) {
484
+ async fetchNewCandidates(client, folder, filters, seenUids, downloadDir, maxFetchLimit = 50) {
485
+ const key = (uid) => `${folder}:${uid}`;
427
486
  const searchCriteria = this.buildSearchCriteria(filters);
428
487
  const uids = await client.search(searchCriteria, { uid: true });
429
488
  if (!uids || uids.length === 0)
430
489
  return [];
431
- const newUids = uids.filter(uid => !seenUids.has(uid));
490
+ const newUids = uids.filter(uid => !seenUids.has(key(uid)));
432
491
  if (newUids.length === 0)
433
492
  return [];
434
493
  // Safety measure: Prevent memory spikes by capping the raw MIME fetch.
@@ -436,11 +495,11 @@ export class EmailClient {
436
495
  if (newUids.length > maxFetchLimit) {
437
496
  log('Warning: Found %d matching emails. Capping fetch limit to the %d most recent to conserve memory.', newUids.length, maxFetchLimit);
438
497
  // Add the omitted UIDs to 'seen' so we don't fetch them in the next polling cycle
439
- newUids.slice(0, -maxFetchLimit).forEach(uid => seenUids.add(uid));
498
+ newUids.slice(0, -maxFetchLimit).forEach(uid => seenUids.add(key(uid)));
440
499
  }
441
500
  const candidates = [];
442
501
  for await (const msg of client.fetch(limitedUids, { source: true }, { uid: true })) {
443
- seenUids.add(msg.uid);
502
+ seenUids.add(key(msg.uid));
444
503
  candidates.push(await this.parseMessage(msg, downloadDir));
445
504
  }
446
505
  return candidates;
package/dist/types.d.ts CHANGED
@@ -104,6 +104,18 @@ export interface EmailReceiveOptions {
104
104
  filters: EmailFilter[];
105
105
  /** IMAP folder to search. Accepts a literal path or a specialUse role (e.g. '\\Sent', '\\Trash'). Defaults to 'INBOX'. */
106
106
  folder?: string;
107
+ /**
108
+ * Multiple IMAP folders to search in one call, each accepting a literal path or a
109
+ * specialUse role. Every folder is searched on every polling cycle and the matches
110
+ * are pooled, so a message the receiving server filed somewhere other than the
111
+ * inbox (spam, for instance) is still found. Takes precedence over `folder`.
112
+ *
113
+ * A folder in this list that does not exist on the server is skipped with a log
114
+ * line rather than throwing, so a portable folder set (e.g. `['INBOX', '\\Junk']`)
115
+ * works against servers that lack one of them. If NONE of the listed folders can be
116
+ * opened, the call throws. A single-folder search still throws on a missing folder.
117
+ */
118
+ folders?: string[];
107
119
  /** How long to poll for a matching email (ms). Defaults to 30000. */
108
120
  waitTimeout?: number;
109
121
  /** Interval between poll attempts (ms). Defaults to 3000. */
@@ -132,6 +144,22 @@ export interface ReceivedEmail {
132
144
  /** Plain-text content. */
133
145
  text: string;
134
146
  }
147
+ /**
148
+ * Options for deleting emails from the mailbox.
149
+ */
150
+ export interface EmailCleanOptions {
151
+ /** Filters identifying which emails to delete. If omitted, EVERY email in the target folder(s) is deleted. */
152
+ filters?: EmailFilter[];
153
+ /** The target mailbox folder. Accepts a literal path or a specialUse role. Defaults to 'INBOX'. */
154
+ folder?: string;
155
+ /**
156
+ * Multiple target folders, each accepting a literal path or a specialUse role.
157
+ * The delete is applied to each folder in turn and the returned count is the total.
158
+ * Takes precedence over `folder`. Missing folders are skipped (see
159
+ * {@link EmailReceiveOptions.folders}).
160
+ */
161
+ folders?: string[];
162
+ }
135
163
  /**
136
164
  * Predefined actions for modifying email flags or state.
137
165
  */
@@ -152,6 +180,13 @@ export interface EmailMarkOptions {
152
180
  filters?: EmailFilter[];
153
181
  /** The target mailbox folder. Accepts a literal path or a specialUse role (e.g. '\\Trash', '\\Sent'). Defaults to 'INBOX'. */
154
182
  folder?: string;
183
+ /**
184
+ * Multiple source folders to mark across, each accepting a literal path or a
185
+ * specialUse role. The action is applied in each folder in turn and the returned
186
+ * count is the total. Takes precedence over `folder`. Missing folders are skipped
187
+ * (see {@link EmailReceiveOptions.folders}).
188
+ */
189
+ folders?: string[];
155
190
  /** The destination folder for the `ARCHIVED` action. Accepts a literal path or a specialUse role (e.g. '\\Flagged', '\\All'). Defaults to 'Archive'. */
156
191
  archiveFolder?: string;
157
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitas-cerebrum/email-client",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "A generic SMTP/IMAP email client for test automation. Send, receive, search, and clean emails with composable filters.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "scripts": {
16
16
  "clean": "rm -rf dist",
17
17
  "build": "npm run clean && tsc",
18
- "test": "vitest run tests/mime-parsing.spec.ts tests/unit-tests.spec.ts",
18
+ "test": "vitest run tests/mime-parsing.spec.ts tests/unit-tests.spec.ts tests/run-isolation.spec.ts",
19
19
  "test:e2e": "vitest run tests/email-integration.spec.ts",
20
20
  "test:all": "vitest run tests",
21
21
  "test:coverage": "npx test-coverage --format github-table",
@@ -26,9 +26,13 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "debug": "^4.4.3",
29
- "imapflow": "^1.3.1",
30
- "mailparser": "^3.9.8",
31
- "nodemailer": "^8.0.5"
29
+ "imapflow": "^1.3.4",
30
+ "mailparser": "^3.9.13",
31
+ "nodemailer": "^9.0.1"
32
+ },
33
+ "overrides": {
34
+ "nodemailer": "^9.0.1",
35
+ "ip-address": ">=10.3.1"
32
36
  },
33
37
  "devDependencies": {
34
38
  "@civitas-cerebrum/test-coverage": "^0.0.8",