Skip to content

Commit 110f7f4

Browse files
authored
Merge pull request #13 from civitas-cerebrum/expunge-fix
Expunge fix
2 parents 62972a3 + ef3c733 commit 110f7f4

File tree

6 files changed

+187
-19
lines changed

6 files changed

+187
-19
lines changed

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ const allEmails = await client.receiveAll({
182182
| Option | Type | Default | Description |
183183
|---|---|---|---|
184184
| `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'`) |
186186
| `waitTimeout` | `number` | `30000` | Max milliseconds to poll before throwing an error |
187187
| `pollInterval` | `number` | `3000` | Milliseconds to wait between IMAP fetch attempts |
188188
| `expectedCount` | `number` | `1` | Number of matching emails required before returning |
@@ -216,11 +216,11 @@ await client.mark({
216216
filters: [{ type: EmailFilterType.SUBJECT, value: 'Welcome' }]
217217
});
218218

219-
// Move emails to an archive folder
219+
// Move emails to an archive folder (using specialUse role — works across all locales)
220220
await client.mark({
221221
action: EmailMarkAction.ARCHIVED,
222222
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
224224
});
225225

226226
// Apply custom IMAP flags
@@ -244,6 +244,26 @@ await client.clean({
244244
await client.clean();
245245
```
246246

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+
247267
-----
248268

249269
### The `ReceivedEmail` Object

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@civitas-cerebrum/email-client",
3-
"version": "0.0.5",
3+
"version": "0.0.7",
44
"description": "A generic SMTP/IMAP email client for test automation. Send, receive, search, and clean emails with composable filters.",
55
"type": "module",
66
"main": "./dist/index.js",

src/EmailClient.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,12 @@ export class EmailClient {
201201
log('Action UNFLAGGED applied to %d email(s) in "%s"', uids.length, folder);
202202
break;
203203
case EmailMarkAction.ARCHIVED:
204-
await client.messageMove(uids, archiveFolder, { uid: true });
205-
log('Archived %d email(s) from "%s" to "%s"', uids.length, folder, archiveFolder);
204+
const resolvedArchive = await this._resolveFolder(client, archiveFolder);
205+
const moveResult = await client.messageMove(uids, resolvedArchive, { uid: true });
206+
if (!moveResult) {
207+
throw new Error(`Failed to move ${uids.length} email(s) to "${resolvedArchive}". The server rejected the move.`);
208+
}
209+
log('Archived %d email(s) from "%s" to "%s"', uids.length, folder, resolvedArchive);
206210
break;
207211
}
208212
});
@@ -272,8 +276,10 @@ export class EmailClient {
272276
await client.connect();
273277
this.logImapConnection();
274278

279+
const resolvedFolder = await this._resolveFolder(client, folder);
280+
275281
while (Date.now() < deadline) {
276-
await client.mailboxOpen(folder);
282+
await client.mailboxOpen(resolvedFolder);
277283

278284
const candidates = await this.fetchNewCandidates(client, filters, seenUids, downloadDir, maxFetchLimit);
279285
const newMatches = this.applyFilters(candidates, filters);
@@ -298,7 +304,7 @@ export class EmailClient {
298304
await new Promise(resolve => setTimeout(resolve, pollInterval));
299305
}
300306

301-
throw new Error(`Found ${accumulatedMatches.length}/${expectedCount} emails within ${waitTimeout}ms. Searched in "${folder}" for: ${this.formatFilterSummary(filters)}`);
307+
throw new Error(`Found ${accumulatedMatches.length}/${expectedCount} emails within ${waitTimeout}ms. Searched in "${resolvedFolder}" for: ${this.formatFilterSummary(filters)}`);
302308
} finally {
303309
try { await client.logout(); } catch (err) { log('IMAP logout failed (ignored): %o', err); }
304310
}
@@ -320,13 +326,15 @@ export class EmailClient {
320326
await client.connect();
321327
this.logImapConnection();
322328

329+
const resolvedFolder = await this._resolveFolder(client, folder);
330+
323331
try {
324-
await client.mailboxOpen(folder);
332+
await client.mailboxOpen(resolvedFolder);
325333
} catch (err: any) {
326334
if (err.serverResponseCode === 'NONEXISTENT' || err.message.includes('Unknown Mailbox')) {
327335
const available = await this._listAvailableFolders(client);
328336
throw new Error(
329-
`Failed to open folder "${folder}".\n` +
337+
`Failed to open folder "${resolvedFolder}".\n` +
330338
`Available folders on this server: [${available.join(', ')}]\n` +
331339
`Check your ARCHIVE_FOLDER or folder settings.`
332340
);
@@ -338,7 +346,7 @@ export class EmailClient {
338346
? this.buildSearchCriteria(filters)
339347
: { all: true };
340348

341-
const uids = await client.search(searchCriteria);
349+
const uids = await client.search(searchCriteria, { uid: true });
342350

343351
if (!uids || uids.length === 0) {
344352
log('No emails to %s in "%s"', actionName, folder);
@@ -380,6 +388,29 @@ export class EmailClient {
380388
return folders;
381389
}
382390

391+
/**
392+
* Resolves a folder name to its actual IMAP path using `specialUse` metadata.
393+
* Accepts either a literal path (e.g. '[Gmail]/Trash') or a specialUse role
394+
* (e.g. '\\Trash', '\\All', '\\Sent', '\\Flagged', '\\Drafts', '\\Junk').
395+
* Returns the original value if no specialUse match is found.
396+
*/
397+
private async _resolveFolder(client: ImapFlow, folder: string): Promise<string> {
398+
if (!folder.startsWith('\\')) return folder;
399+
400+
const list = await client.list();
401+
for (const entry of list) {
402+
if (entry.specialUse === folder) {
403+
log('Resolved specialUse "%s" to folder "%s"', folder, entry.path);
404+
return entry.path;
405+
}
406+
}
407+
408+
throw new Error(
409+
`No folder with specialUse "${folder}" found on this server. ` +
410+
`Available folders: [${(list as any[]).map((f: any) => `${f.path} (${f.specialUse || 'none'})`).join(', ')}]`
411+
);
412+
}
413+
383414
/** Instantiates an ImapFlow client using the provided credentials. */
384415
private createImapClient(): ImapFlow {
385416
const imap = this.requireImap();
@@ -448,7 +479,7 @@ export class EmailClient {
448479
maxFetchLimit: number = 50
449480
): Promise<ReceivedEmail[]> {
450481
const searchCriteria = this.buildSearchCriteria(filters);
451-
const uids = await client.search(searchCriteria);
482+
const uids = await client.search(searchCriteria, { uid: true });
452483
if (!uids || uids.length === 0) return [];
453484

454485
const newUids = uids.filter(uid => !seenUids.has(uid));
@@ -464,7 +495,7 @@ export class EmailClient {
464495
}
465496

466497
const candidates: ReceivedEmail[] = [];
467-
for await (const msg of client.fetch(limitedUids, { source: true, uid: true })) {
498+
for await (const msg of client.fetch(limitedUids, { source: true }, { uid: true })) {
468499
seenUids.add(msg.uid);
469500
candidates.push(await this.parseMessage(msg, downloadDir));
470501
}

src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export interface EmailSendOptions {
109109
export interface EmailReceiveOptions {
110110
/** Array of filters to apply when searching for emails. All filters are combined (AND logic). */
111111
filters: EmailFilter[];
112-
/** IMAP folder to search. Defaults to 'INBOX'. */
112+
/** IMAP folder to search. Accepts a literal path or a specialUse role (e.g. '\\Sent', '\\Trash'). Defaults to 'INBOX'. */
113113
folder?: string;
114114
/** How long to poll for a matching email (ms). Defaults to 30000. */
115115
waitTimeout?: number;
@@ -160,8 +160,8 @@ export interface EmailMarkOptions {
160160
action: EmailMarkAction | string[];
161161
/** Filters to identify which emails should be marked. If omitted, applies to all emails in the folder. */
162162
filters?: EmailFilter[];
163-
/** The target mailbox folder to perform the action in. Defaults to 'INBOX'. */
163+
/** The target mailbox folder. Accepts a literal path or a specialUse role (e.g. '\\Trash', '\\Sent'). Defaults to 'INBOX'. */
164164
folder?: string;
165-
/** The destination folder used when the `ARCHIVED` action is triggered. Defaults to 'Archive'. */
165+
/** The destination folder for the `ARCHIVED` action. Accepts a literal path or a specialUse role (e.g. '\\Flagged', '\\All'). Defaults to 'Archive'. */
166166
archiveFolder?: string;
167167
}

tests/email-integration.spec.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,35 @@ import { describe, test, expect, beforeAll } from 'vitest';
22
import * as fs from 'fs';
33
import * as path from 'path';
44
import * as os from 'os';
5+
import { ImapFlow } from 'imapflow';
56
import { EmailClient, EmailFilterType, EmailMarkAction } from '../src';
67

8+
/** Opens a raw ImapFlow connection and returns the flags for emails matching the given subject. */
9+
async function getImapFlags(subject: string): Promise<Set<string>[]> {
10+
const client = new ImapFlow({
11+
host: 'imap.gmail.com',
12+
port: 993,
13+
secure: true,
14+
auth: { user: process.env.RECEIVER_EMAIL!, pass: process.env.RECEIVER_PASSWORD! },
15+
logger: false,
16+
});
17+
18+
try {
19+
await client.connect();
20+
await client.mailboxOpen('INBOX');
21+
const uids = await client.search({ subject }, { uid: true });
22+
if (!uids || uids.length === 0) return [];
23+
24+
const results: Set<string>[] = [];
25+
for await (const msg of client.fetch(uids, { flags: true }, { uid: true })) {
26+
results.push(msg.flags);
27+
}
28+
return results;
29+
} finally {
30+
try { await client.logout(); } catch { /* ignore */ }
31+
}
32+
}
33+
734
describe('EmailClient Integration Workflows', () => {
835
let emailClient: EmailClient;
936
const TIMEOUT = 120000;
@@ -225,6 +252,51 @@ describe('EmailClient Integration Workflows', () => {
225252
await emailClient.clean({ filters: filterCriteria });
226253
});
227254

255+
test('should verify mark() actually modifies the correct email flags on the server', { timeout: 180000 }, async () => {
256+
const uniqueSubject = `Mark Verify Flags ${Date.now()}`;
257+
const recipient = process.env.RECEIVER_EMAIL!;
258+
259+
await emailClient.send({
260+
to: recipient,
261+
subject: uniqueSubject,
262+
text: 'Verifying flags are applied to the correct email.',
263+
});
264+
265+
await emailClient.receive({
266+
filters: [{ type: EmailFilterType.SUBJECT, value: uniqueSubject }],
267+
waitTimeout: TIMEOUT,
268+
pollInterval: POLLING,
269+
});
270+
271+
const filterCriteria = [{ type: EmailFilterType.SUBJECT, value: uniqueSubject }];
272+
273+
// Mark as READ and verify \\Seen flag is present
274+
await emailClient.mark({ action: EmailMarkAction.READ, filters: filterCriteria });
275+
let flags = await getImapFlags(uniqueSubject);
276+
expect(flags).toHaveLength(1);
277+
expect(flags[0].has('\\Seen')).toBe(true);
278+
279+
// Mark as UNREAD and verify \\Seen flag is removed
280+
await emailClient.mark({ action: EmailMarkAction.UNREAD, filters: filterCriteria });
281+
flags = await getImapFlags(uniqueSubject);
282+
expect(flags).toHaveLength(1);
283+
expect(flags[0].has('\\Seen')).toBe(false);
284+
285+
// Mark as FLAGGED and verify \\Flagged is present
286+
await emailClient.mark({ action: EmailMarkAction.FLAGGED, filters: filterCriteria });
287+
flags = await getImapFlags(uniqueSubject);
288+
expect(flags).toHaveLength(1);
289+
expect(flags[0].has('\\Flagged')).toBe(true);
290+
291+
// Mark as UNFLAGGED and verify \\Flagged is removed
292+
await emailClient.mark({ action: EmailMarkAction.UNFLAGGED, filters: filterCriteria });
293+
flags = await getImapFlags(uniqueSubject);
294+
expect(flags).toHaveLength(1);
295+
expect(flags[0].has('\\Flagged')).toBe(false);
296+
297+
await emailClient.clean({ filters: filterCriteria });
298+
});
299+
228300
test('should apply custom IMAP string flags using mark()', async () => {
229301
const uniqueSubject = `Mark Custom Flags Test ${Date.now()}`;
230302
const recipient = process.env.RECEIVER_EMAIL!;
@@ -418,6 +490,33 @@ describe('EmailClient Integration Workflows', () => {
418490
).rejects.toThrow(/Failed to open folder "Trash"/i);
419491
});
420492

493+
test('should verify emails are permanently removed after clean()', async () => {
494+
const uniqueSubject = `CleanVerify-${Date.now()}`;
495+
const recipient = process.env.RECEIVER_EMAIL!;
496+
497+
await emailClient.send({ to: recipient, subject: uniqueSubject, text: 'This email should be deleted.' });
498+
499+
await emailClient.receive({
500+
filters: [{ type: EmailFilterType.SUBJECT, value: uniqueSubject }],
501+
waitTimeout: TIMEOUT,
502+
pollInterval: POLLING,
503+
});
504+
505+
const deletedCount = await emailClient.clean({
506+
filters: [{ type: EmailFilterType.SUBJECT, value: uniqueSubject }],
507+
});
508+
expect(deletedCount).toBe(1);
509+
510+
// Verify the email is actually gone by attempting to receive it again
511+
await expect(
512+
emailClient.receive({
513+
filters: [{ type: EmailFilterType.SUBJECT, value: uniqueSubject }],
514+
waitTimeout: 15000,
515+
pollInterval: POLLING,
516+
})
517+
).rejects.toThrow(/within 15000ms/);
518+
});
519+
421520
test('should delete ALL emails in INBOX when called with no options', async () => {
422521
const batchId = `CleanAll-${Date.now()}`;
423522
const recipient = process.env.RECEIVER_EMAIL!;
@@ -471,7 +570,7 @@ describe('EmailClient Integration Workflows', () => {
471570
const uniqueSubject = `Mark ARCHIVED Test ${Date.now()}`;
472571
const recipient = process.env.RECEIVER_EMAIL!;
473572

474-
const testArchiveFolder = '[Gmail]/Taslaklar';
573+
const testArchiveFolder = '\\Flagged';
475574

476575
await emailClient.send({ to: recipient, subject: uniqueSubject, text: 'archive test' });
477576
await emailClient.receive({
@@ -487,6 +586,24 @@ describe('EmailClient Integration Workflows', () => {
487586

488587
expect(count).toBe(1);
489588

589+
// Verify the email is no longer in INBOX
590+
await expect(
591+
emailClient.receive({
592+
filters: [{ type: EmailFilterType.SUBJECT, value: uniqueSubject }],
593+
waitTimeout: 15000,
594+
pollInterval: POLLING,
595+
})
596+
).rejects.toThrow(/within 15000ms/);
597+
598+
// Verify the email arrived in the archive folder
599+
const archived = await emailClient.receive({
600+
filters: [{ type: EmailFilterType.SUBJECT, value: uniqueSubject }],
601+
folder: testArchiveFolder,
602+
waitTimeout: TIMEOUT,
603+
pollInterval: POLLING,
604+
});
605+
expect(archived.subject).toContain(uniqueSubject);
606+
490607
await emailClient.clean({
491608
filters: [{ type: EmailFilterType.SUBJECT, value: uniqueSubject }],
492609
folder: testArchiveFolder,

0 commit comments

Comments
 (0)