@civitas-cerebrum/email-client 0.0.5 → 0.0.7

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
@@ -182,7 +182,7 @@ const allEmails = await client.receiveAll({
182
182
  | Option | Type | Default | Description |
183
183
  |---|---|---|---|
184
184
  | `filters` | `EmailFilter[]` | — | **Required.** Array of filters (AND logic) |
185
- | `folder` | `string` | `'INBOX'` | IMAP folder to search |
185
+ | `folder` | `string` | `'INBOX'` | IMAP folder to search. Accepts a literal path or a `specialUse` role (e.g. `'\\Sent'`, `'\\Trash'`) |
186
186
  | `waitTimeout` | `number` | `30000` | Max milliseconds to poll before throwing an error |
187
187
  | `pollInterval` | `number` | `3000` | Milliseconds to wait between IMAP fetch attempts |
188
188
  | `expectedCount` | `number` | `1` | Number of matching emails required before returning |
@@ -216,11 +216,11 @@ await client.mark({
216
216
  filters: [{ type: EmailFilterType.SUBJECT, value: 'Welcome' }]
217
217
  });
218
218
 
219
- // Move emails to an archive folder
219
+ // Move emails to an archive folder (using specialUse role — works across all locales)
220
220
  await client.mark({
221
221
  action: EmailMarkAction.ARCHIVED,
222
222
  filters: [{ type: EmailFilterType.FROM, value: 'spam@example.com' }],
223
- archiveFolder: 'Archive' // Note: This must match the server's localized folder name
223
+ archiveFolder: '\\Trash' // Resolves to the server's actual Trash path at runtime
224
224
  });
225
225
 
226
226
  // Apply custom IMAP flags
@@ -244,6 +244,26 @@ await client.clean({
244
244
  await client.clean();
245
245
  ```
246
246
 
247
+ #### Folder Resolution
248
+
249
+ 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.
250
+
251
+ | Role | Description |
252
+ |---|---|
253
+ | `\All` | All Mail |
254
+ | `\Trash` | Trash / Deleted Items |
255
+ | `\Sent` | Sent Mail |
256
+ | `\Drafts` | Drafts |
257
+ | `\Junk` | Spam |
258
+ | `\Flagged` | Starred / Flagged |
259
+ | `\Inbox` | Inbox |
260
+
261
+ ```ts
262
+ // These are equivalent on a Turkish Gmail account:
263
+ await client.clean({ folder: '[Gmail]/Çöp kutusu' }); // literal path
264
+ await client.clean({ folder: '\\Trash' }); // specialUse role (locale-independent)
265
+ ```
266
+
247
267
  -----
248
268
 
249
269
  ### The `ReceivedEmail` Object
@@ -88,6 +88,13 @@ export declare class EmailClient {
88
88
  * Helper to scrape mailbox paths for better error reporting
89
89
  */
90
90
  private _listAvailableFolders;
91
+ /**
92
+ * Resolves a folder name to its actual IMAP path using `specialUse` metadata.
93
+ * Accepts either a literal path (e.g. '[Gmail]/Trash') or a specialUse role
94
+ * (e.g. '\\Trash', '\\All', '\\Sent', '\\Flagged', '\\Drafts', '\\Junk').
95
+ * Returns the original value if no specialUse match is found.
96
+ */
97
+ private _resolveFolder;
91
98
  /** Instantiates an ImapFlow client using the provided credentials. */
92
99
  private createImapClient;
93
100
  /** Logs standard IMAP connection details. */
@@ -168,8 +168,12 @@ export class EmailClient {
168
168
  log('Action UNFLAGGED applied to %d email(s) in "%s"', uids.length, folder);
169
169
  break;
170
170
  case EmailMarkAction.ARCHIVED:
171
- await client.messageMove(uids, archiveFolder, { uid: true });
172
- log('Archived %d email(s) from "%s" to "%s"', uids.length, folder, archiveFolder);
171
+ const resolvedArchive = await this._resolveFolder(client, archiveFolder);
172
+ const moveResult = await client.messageMove(uids, resolvedArchive, { uid: true });
173
+ if (!moveResult) {
174
+ throw new Error(`Failed to move ${uids.length} email(s) to "${resolvedArchive}". The server rejected the move.`);
175
+ }
176
+ log('Archived %d email(s) from "%s" to "%s"', uids.length, folder, resolvedArchive);
173
177
  break;
174
178
  }
175
179
  });
@@ -230,8 +234,9 @@ export class EmailClient {
230
234
  try {
231
235
  await client.connect();
232
236
  this.logImapConnection();
237
+ const resolvedFolder = await this._resolveFolder(client, folder);
233
238
  while (Date.now() < deadline) {
234
- await client.mailboxOpen(folder);
239
+ await client.mailboxOpen(resolvedFolder);
235
240
  const candidates = await this.fetchNewCandidates(client, filters, seenUids, downloadDir, maxFetchLimit);
236
241
  const newMatches = this.applyFilters(candidates, filters);
237
242
  accumulatedMatches.push(...newMatches);
@@ -251,7 +256,7 @@ export class EmailClient {
251
256
  }
252
257
  await new Promise(resolve => setTimeout(resolve, pollInterval));
253
258
  }
254
- throw new Error(`Found ${accumulatedMatches.length}/${expectedCount} emails within ${waitTimeout}ms. Searched in "${folder}" for: ${this.formatFilterSummary(filters)}`);
259
+ throw new Error(`Found ${accumulatedMatches.length}/${expectedCount} emails within ${waitTimeout}ms. Searched in "${resolvedFolder}" for: ${this.formatFilterSummary(filters)}`);
255
260
  }
256
261
  finally {
257
262
  try {
@@ -271,13 +276,14 @@ export class EmailClient {
271
276
  try {
272
277
  await client.connect();
273
278
  this.logImapConnection();
279
+ const resolvedFolder = await this._resolveFolder(client, folder);
274
280
  try {
275
- await client.mailboxOpen(folder);
281
+ await client.mailboxOpen(resolvedFolder);
276
282
  }
277
283
  catch (err) {
278
284
  if (err.serverResponseCode === 'NONEXISTENT' || err.message.includes('Unknown Mailbox')) {
279
285
  const available = await this._listAvailableFolders(client);
280
- throw new Error(`Failed to open folder "${folder}".\n` +
286
+ throw new Error(`Failed to open folder "${resolvedFolder}".\n` +
281
287
  `Available folders on this server: [${available.join(', ')}]\n` +
282
288
  `Check your ARCHIVE_FOLDER or folder settings.`);
283
289
  }
@@ -286,7 +292,7 @@ export class EmailClient {
286
292
  const searchCriteria = filters && filters.length > 0
287
293
  ? this.buildSearchCriteria(filters)
288
294
  : { all: true };
289
- const uids = await client.search(searchCriteria);
295
+ const uids = await client.search(searchCriteria, { uid: true });
290
296
  if (!uids || uids.length === 0) {
291
297
  log('No emails to %s in "%s"', actionName, folder);
292
298
  return 0;
@@ -329,6 +335,25 @@ export class EmailClient {
329
335
  }
330
336
  return folders;
331
337
  }
338
+ /**
339
+ * Resolves a folder name to its actual IMAP path using `specialUse` metadata.
340
+ * Accepts either a literal path (e.g. '[Gmail]/Trash') or a specialUse role
341
+ * (e.g. '\\Trash', '\\All', '\\Sent', '\\Flagged', '\\Drafts', '\\Junk').
342
+ * Returns the original value if no specialUse match is found.
343
+ */
344
+ async _resolveFolder(client, folder) {
345
+ if (!folder.startsWith('\\'))
346
+ return folder;
347
+ const list = await client.list();
348
+ for (const entry of list) {
349
+ if (entry.specialUse === folder) {
350
+ log('Resolved specialUse "%s" to folder "%s"', folder, entry.path);
351
+ return entry.path;
352
+ }
353
+ }
354
+ throw new Error(`No folder with specialUse "${folder}" found on this server. ` +
355
+ `Available folders: [${list.map((f) => `${f.path} (${f.specialUse || 'none'})`).join(', ')}]`);
356
+ }
332
357
  /** Instantiates an ImapFlow client using the provided credentials. */
333
358
  createImapClient() {
334
359
  const imap = this.requireImap();
@@ -400,7 +425,7 @@ export class EmailClient {
400
425
  /** Fetches unread/unseen messages from IMAP that match the search criteria. */
401
426
  async fetchNewCandidates(client, filters, seenUids, downloadDir, maxFetchLimit = 50) {
402
427
  const searchCriteria = this.buildSearchCriteria(filters);
403
- const uids = await client.search(searchCriteria);
428
+ const uids = await client.search(searchCriteria, { uid: true });
404
429
  if (!uids || uids.length === 0)
405
430
  return [];
406
431
  const newUids = uids.filter(uid => !seenUids.has(uid));
@@ -414,7 +439,7 @@ export class EmailClient {
414
439
  newUids.slice(0, -maxFetchLimit).forEach(uid => seenUids.add(uid));
415
440
  }
416
441
  const candidates = [];
417
- for await (const msg of client.fetch(limitedUids, { source: true, uid: true })) {
442
+ for await (const msg of client.fetch(limitedUids, { source: true }, { uid: true })) {
418
443
  seenUids.add(msg.uid);
419
444
  candidates.push(await this.parseMessage(msg, downloadDir));
420
445
  }
package/dist/types.d.ts CHANGED
@@ -102,7 +102,7 @@ export interface EmailSendOptions {
102
102
  export interface EmailReceiveOptions {
103
103
  /** Array of filters to apply when searching for emails. All filters are combined (AND logic). */
104
104
  filters: EmailFilter[];
105
- /** IMAP folder to search. Defaults to 'INBOX'. */
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
107
  /** How long to poll for a matching email (ms). Defaults to 30000. */
108
108
  waitTimeout?: number;
@@ -150,8 +150,8 @@ export interface EmailMarkOptions {
150
150
  action: EmailMarkAction | string[];
151
151
  /** Filters to identify which emails should be marked. If omitted, applies to all emails in the folder. */
152
152
  filters?: EmailFilter[];
153
- /** The target mailbox folder to perform the action in. Defaults to 'INBOX'. */
153
+ /** The target mailbox folder. Accepts a literal path or a specialUse role (e.g. '\\Trash', '\\Sent'). Defaults to 'INBOX'. */
154
154
  folder?: string;
155
- /** The destination folder used when the `ARCHIVED` action is triggered. Defaults to 'Archive'. */
155
+ /** The destination folder for the `ARCHIVED` action. Accepts a literal path or a specialUse role (e.g. '\\Flagged', '\\All'). Defaults to 'Archive'. */
156
156
  archiveFolder?: string;
157
157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitas-cerebrum/email-client",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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",
@@ -26,9 +26,9 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "debug": "^4.4.3",
29
- "imapflow": "^1.2.16",
30
- "mailparser": "^3.9.5",
31
- "nodemailer": "^8.0.3"
29
+ "imapflow": "^1.3.1",
30
+ "mailparser": "^3.9.8",
31
+ "nodemailer": "^8.0.5"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@civitas-cerebrum/test-coverage": "^0.0.8",
@@ -39,8 +39,8 @@
39
39
  "dotenv": "^17.3.1",
40
40
  "jsdom": "^25.0.1",
41
41
  "typescript": "^5.0.0",
42
- "vite": "^6.0.1",
43
- "vitest": "^2.1.5"
42
+ "vite": "^6.4.2",
43
+ "vitest": "^4.1.4"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public",