@civitas-cerebrum/email-client 0.0.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/README.md +171 -0
- package/dist/EmailClient.d.ts +26 -0
- package/dist/EmailClient.js +338 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +14 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.js +19 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# @civitas-cerebrum/email-client
|
|
2
|
+
|
|
3
|
+
[)](https://www.npmjs.com/package/@civitas-cerebrum/email-client)
|
|
4
|
+
|
|
5
|
+
A generic SMTP/IMAP email client for test automation. Send, receive, search, and clean emails with composable filters.
|
|
6
|
+
|
|
7
|
+
- **Zero Playwright runtime dependency** — works with any test runner
|
|
8
|
+
- **Composable filters** — combine subject, sender, content, date, and more with AND logic
|
|
9
|
+
- **Two-phase matching** — exact match first, partial case-insensitive fallback
|
|
10
|
+
- **Full inbox management** — send, receive, receive all, and clean
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i @civitas-cerebrum/email-client
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { EmailClient, EmailFilterType } from '@civitas-cerebrum/email-client';
|
|
22
|
+
|
|
23
|
+
const client = new EmailClient({
|
|
24
|
+
senderEmail: 'sender@example.com',
|
|
25
|
+
senderPassword: 'app-password',
|
|
26
|
+
senderSmtpHost: 'smtp.example.com',
|
|
27
|
+
receiverEmail: 'receiver@example.com',
|
|
28
|
+
receiverPassword: 'app-password',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Send an email
|
|
32
|
+
await client.send({
|
|
33
|
+
to: 'user@example.com',
|
|
34
|
+
subject: 'Your OTP Code',
|
|
35
|
+
text: 'Your code is 123456',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Receive the latest matching email
|
|
39
|
+
const email = await client.receive({
|
|
40
|
+
filters: [{ type: EmailFilterType.SUBJECT, value: 'Your OTP Code' }],
|
|
41
|
+
});
|
|
42
|
+
console.log(email.subject, email.text);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
### Constructor
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const client = new EmailClient(credentials: EmailCredentials);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Field | Type | Default | Description |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| `senderEmail` | `string` | — | SMTP sender email address |
|
|
56
|
+
| `senderPassword` | `string` | — | SMTP sender password or app password |
|
|
57
|
+
| `senderSmtpHost` | `string` | — | SMTP host (e.g. `'smtp.gmail.com'`) |
|
|
58
|
+
| `senderSmtpPort` | `number` | `587` | SMTP port |
|
|
59
|
+
| `receiverEmail` | `string` | — | IMAP receiver email address |
|
|
60
|
+
| `receiverPassword` | `string` | — | IMAP receiver password or app password |
|
|
61
|
+
| `receiverImapHost` | `string` | `'imap.gmail.com'` | IMAP host |
|
|
62
|
+
| `receiverImapPort` | `number` | `993` | IMAP port |
|
|
63
|
+
|
|
64
|
+
### Sending Emails
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// Plain text
|
|
68
|
+
await client.send({ to: 'user@example.com', subject: 'Test', text: 'Hello' });
|
|
69
|
+
|
|
70
|
+
// Inline HTML
|
|
71
|
+
await client.send({ to: 'user@example.com', subject: 'Report', html: '<h1>Results</h1>' });
|
|
72
|
+
|
|
73
|
+
// HTML file template
|
|
74
|
+
await client.send({ to: 'user@example.com', subject: 'Report', htmlFile: 'emails/report.html' });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Receiving Emails
|
|
78
|
+
|
|
79
|
+
Use composable filters to search for emails. Combine as many filters as needed — all filters are applied with AND logic. Filtering tries exact match first, then falls back to partial case-insensitive match (with a warning log).
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// Single filter — get the latest matching email
|
|
83
|
+
const email = await client.receive({
|
|
84
|
+
filters: [{ type: EmailFilterType.SUBJECT, value: 'Your OTP' }],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Multiple filters — combine subject, sender, and content
|
|
88
|
+
const email2 = await client.receive({
|
|
89
|
+
filters: [
|
|
90
|
+
{ type: EmailFilterType.SUBJECT, value: 'Verification' },
|
|
91
|
+
{ type: EmailFilterType.FROM, value: 'noreply@example.com' },
|
|
92
|
+
{ type: EmailFilterType.CONTENT, value: 'verification code' },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Get ALL matching emails
|
|
97
|
+
const allEmails = await client.receiveAll({
|
|
98
|
+
filters: [
|
|
99
|
+
{ type: EmailFilterType.FROM, value: 'alerts@example.com' },
|
|
100
|
+
{ type: EmailFilterType.SINCE, value: new Date('2025-01-01') },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Receive Options
|
|
106
|
+
|
|
107
|
+
| Option | Type | Default | Description |
|
|
108
|
+
|---|---|---|---|
|
|
109
|
+
| `filters` | `EmailFilter[]` | — | **Required.** Array of filters (AND logic) |
|
|
110
|
+
| `folder` | `string` | `'INBOX'` | IMAP folder to search |
|
|
111
|
+
| `waitTimeout` | `number` | `30000` | Max ms to poll for the email |
|
|
112
|
+
| `pollInterval` | `number` | `3000` | Ms between poll attempts |
|
|
113
|
+
| `downloadDir` | `string` | `os.tmpdir()/pw-emails` | Where to save downloaded HTML |
|
|
114
|
+
|
|
115
|
+
### Filter Types
|
|
116
|
+
|
|
117
|
+
| Type | Value | Description |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `EmailFilterType.SUBJECT` | `string` | Filter by email subject |
|
|
120
|
+
| `EmailFilterType.FROM` | `string` | Filter by sender address |
|
|
121
|
+
| `EmailFilterType.TO` | `string` | Filter by recipient address |
|
|
122
|
+
| `EmailFilterType.CONTENT` | `string` | Filter by email body (HTML or plain text) |
|
|
123
|
+
| `EmailFilterType.SINCE` | `Date` | Only include emails after this date |
|
|
124
|
+
|
|
125
|
+
### Cleaning the Inbox
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// Delete emails matching filters
|
|
129
|
+
await client.clean({
|
|
130
|
+
filters: [{ type: EmailFilterType.FROM, value: 'noreply@example.com' }],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Delete all emails in the inbox
|
|
134
|
+
await client.clean();
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Client-Side Filtering
|
|
138
|
+
|
|
139
|
+
`applyFilters` is public — use it to filter already-fetched emails:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const filtered = client.applyFilters(emails, [
|
|
143
|
+
{ type: EmailFilterType.SUBJECT, value: 'OTP' },
|
|
144
|
+
]);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## ReceivedEmail
|
|
148
|
+
|
|
149
|
+
Each received email is downloaded as an HTML file and returned as:
|
|
150
|
+
|
|
151
|
+
| Field | Type | Description |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `filePath` | `string` | Local path to the downloaded HTML file |
|
|
154
|
+
| `subject` | `string` | Email subject |
|
|
155
|
+
| `from` | `string` | Sender address |
|
|
156
|
+
| `date` | `Date` | Date the email was sent |
|
|
157
|
+
| `html` | `string` | Raw HTML content (empty string if plain-text only) |
|
|
158
|
+
| `text` | `string` | Plain-text content |
|
|
159
|
+
|
|
160
|
+
## Contributing
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
git clone https://github.com/Umutayb/email-client.git
|
|
164
|
+
cd email-client
|
|
165
|
+
npm install
|
|
166
|
+
npm test
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { EmailSendOptions, EmailReceiveOptions, EmailCredentials, ReceivedEmail, EmailFilter } from './types';
|
|
2
|
+
export declare class EmailClient {
|
|
3
|
+
private credentials;
|
|
4
|
+
private smtpTransport;
|
|
5
|
+
constructor(credentials: EmailCredentials);
|
|
6
|
+
send(options: EmailSendOptions): Promise<void>;
|
|
7
|
+
receive(options: EmailReceiveOptions): Promise<ReceivedEmail>;
|
|
8
|
+
receiveAll(options: EmailReceiveOptions): Promise<ReceivedEmail[]>;
|
|
9
|
+
clean(options?: {
|
|
10
|
+
filters?: EmailFilter[];
|
|
11
|
+
folder?: string;
|
|
12
|
+
}): Promise<number>;
|
|
13
|
+
applyFilters(candidates: ReceivedEmail[], filters: EmailFilter[]): ReceivedEmail[];
|
|
14
|
+
private validateFilters;
|
|
15
|
+
private createImapClient;
|
|
16
|
+
private logImapConnection;
|
|
17
|
+
private buildSearchCriteria;
|
|
18
|
+
private fetchCandidates;
|
|
19
|
+
private matchesAllFilters;
|
|
20
|
+
private getEmailField;
|
|
21
|
+
private formatFilterSummary;
|
|
22
|
+
private parseMessage;
|
|
23
|
+
private getSmtpTransport;
|
|
24
|
+
extractHtmlFromSource(source: string): string;
|
|
25
|
+
extractTextFromSource(source: string): string;
|
|
26
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.EmailClient = void 0;
|
|
37
|
+
const nodemailer = __importStar(require("nodemailer"));
|
|
38
|
+
const imapflow_1 = require("imapflow");
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const logger_1 = require("./logger");
|
|
43
|
+
const types_1 = require("./types");
|
|
44
|
+
const log = (0, logger_1.createLogger)('imap');
|
|
45
|
+
const smtpLog = (0, logger_1.createLogger)('smtp');
|
|
46
|
+
class EmailClient {
|
|
47
|
+
credentials;
|
|
48
|
+
smtpTransport = null;
|
|
49
|
+
constructor(credentials) {
|
|
50
|
+
this.credentials = credentials;
|
|
51
|
+
}
|
|
52
|
+
// ─── SMTP ────────────────────────────────────────────────────────────
|
|
53
|
+
async send(options) {
|
|
54
|
+
const transport = this.getSmtpTransport();
|
|
55
|
+
const { to, subject, text, html, htmlFile } = options;
|
|
56
|
+
let htmlContent = html;
|
|
57
|
+
if (htmlFile) {
|
|
58
|
+
const resolvedPath = path.resolve(htmlFile);
|
|
59
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
60
|
+
throw new Error(`HTML file not found: ${resolvedPath}`);
|
|
61
|
+
}
|
|
62
|
+
htmlContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
63
|
+
smtpLog('Loaded HTML email body from %s (%d bytes)', resolvedPath, htmlContent.length);
|
|
64
|
+
}
|
|
65
|
+
const mailOptions = {
|
|
66
|
+
from: this.credentials.senderEmail,
|
|
67
|
+
to,
|
|
68
|
+
subject,
|
|
69
|
+
...(htmlContent ? { html: htmlContent } : { text: text ?? '' })
|
|
70
|
+
};
|
|
71
|
+
const info = await transport.sendMail(mailOptions);
|
|
72
|
+
smtpLog('Email sent to %s — messageId: %s', to, info.messageId);
|
|
73
|
+
}
|
|
74
|
+
// ─── IMAP ────────────────────────────────────────────────────────────
|
|
75
|
+
async receive(options) {
|
|
76
|
+
const { filters, folder, waitTimeout, pollInterval, downloadDir } = options;
|
|
77
|
+
this.validateFilters(filters);
|
|
78
|
+
const timeout = waitTimeout ?? 30000;
|
|
79
|
+
const interval = pollInterval ?? 3000;
|
|
80
|
+
const mailbox = folder ?? 'INBOX';
|
|
81
|
+
const deadline = Date.now() + timeout;
|
|
82
|
+
const client = this.createImapClient();
|
|
83
|
+
try {
|
|
84
|
+
await client.connect();
|
|
85
|
+
this.logImapConnection();
|
|
86
|
+
while (Date.now() < deadline) {
|
|
87
|
+
await client.mailboxOpen(mailbox);
|
|
88
|
+
const candidates = await this.fetchCandidates(client, filters, downloadDir);
|
|
89
|
+
const result = this.applyFilters(candidates, filters);
|
|
90
|
+
if (result.length > 0) {
|
|
91
|
+
return result[result.length - 1];
|
|
92
|
+
}
|
|
93
|
+
log('No matching email found yet, retrying in %dms...', interval);
|
|
94
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`No email matching criteria found within ${timeout}ms. Searched in "${mailbox}" for: ${this.formatFilterSummary(filters)}`);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
try {
|
|
100
|
+
await client.logout();
|
|
101
|
+
}
|
|
102
|
+
catch { /* already disconnected */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async receiveAll(options) {
|
|
106
|
+
const { filters, folder, waitTimeout, pollInterval, downloadDir } = options;
|
|
107
|
+
this.validateFilters(filters);
|
|
108
|
+
const timeout = waitTimeout ?? 30000;
|
|
109
|
+
const interval = pollInterval ?? 3000;
|
|
110
|
+
const mailbox = folder ?? 'INBOX';
|
|
111
|
+
const deadline = Date.now() + timeout;
|
|
112
|
+
const client = this.createImapClient();
|
|
113
|
+
try {
|
|
114
|
+
await client.connect();
|
|
115
|
+
this.logImapConnection();
|
|
116
|
+
while (Date.now() < deadline) {
|
|
117
|
+
await client.mailboxOpen(mailbox);
|
|
118
|
+
const candidates = await this.fetchCandidates(client, filters, downloadDir);
|
|
119
|
+
const results = this.applyFilters(candidates, filters);
|
|
120
|
+
if (results.length > 0) {
|
|
121
|
+
log('Found %d matching email(s)', results.length);
|
|
122
|
+
return results;
|
|
123
|
+
}
|
|
124
|
+
log('No matching emails found yet, retrying in %dms...', interval);
|
|
125
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`No emails matching criteria found within ${timeout}ms. Searched in "${mailbox}" for: ${this.formatFilterSummary(filters)}`);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
try {
|
|
131
|
+
await client.logout();
|
|
132
|
+
}
|
|
133
|
+
catch { /* already disconnected */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async clean(options) {
|
|
137
|
+
const filters = options?.filters;
|
|
138
|
+
const mailbox = options?.folder ?? 'INBOX';
|
|
139
|
+
const client = this.createImapClient();
|
|
140
|
+
try {
|
|
141
|
+
await client.connect();
|
|
142
|
+
this.logImapConnection();
|
|
143
|
+
await client.mailboxOpen(mailbox);
|
|
144
|
+
const searchCriteria = filters && filters.length > 0
|
|
145
|
+
? this.buildSearchCriteria(filters)
|
|
146
|
+
: { all: true };
|
|
147
|
+
const uids = [];
|
|
148
|
+
for await (const msg of client.fetch({ ...searchCriteria }, { uid: true })) {
|
|
149
|
+
uids.push(msg.uid);
|
|
150
|
+
}
|
|
151
|
+
if (uids.length > 0) {
|
|
152
|
+
await client.messageDelete(uids, { uid: true });
|
|
153
|
+
log('Deleted %d email(s) from "%s"', uids.length, mailbox);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
log('No emails to delete in "%s"', mailbox);
|
|
157
|
+
}
|
|
158
|
+
await client.logout();
|
|
159
|
+
return uids.length;
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
try {
|
|
163
|
+
await client.logout();
|
|
164
|
+
}
|
|
165
|
+
catch { /* already disconnected */ }
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ─── Public filtering API ────────────────────────────────────────
|
|
170
|
+
applyFilters(candidates, filters) {
|
|
171
|
+
const stringFilters = filters.filter(f => f.type !== types_1.EmailFilterType.SINCE && f.type !== types_1.EmailFilterType.TO);
|
|
172
|
+
if (stringFilters.length === 0)
|
|
173
|
+
return candidates;
|
|
174
|
+
const exactMatches = candidates.filter(email => this.matchesAllFilters(email, stringFilters, true));
|
|
175
|
+
if (exactMatches.length > 0)
|
|
176
|
+
return exactMatches;
|
|
177
|
+
const partialMatches = candidates.filter(email => this.matchesAllFilters(email, stringFilters, false));
|
|
178
|
+
if (partialMatches.length > 0) {
|
|
179
|
+
log('No exact match found — falling back to partial case-insensitive match for: %s', this.formatFilterSummary(stringFilters));
|
|
180
|
+
}
|
|
181
|
+
return partialMatches;
|
|
182
|
+
}
|
|
183
|
+
// ─── Private helpers ─────────────────────────────────────────────
|
|
184
|
+
validateFilters(filters) {
|
|
185
|
+
if (!filters || filters.length === 0) {
|
|
186
|
+
throw new Error('At least one email filter is required. Use EmailFilterType to specify filter criteria.');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
createImapClient() {
|
|
190
|
+
return new imapflow_1.ImapFlow({
|
|
191
|
+
host: this.credentials.receiverImapHost ?? 'imap.gmail.com',
|
|
192
|
+
port: this.credentials.receiverImapPort ?? 993,
|
|
193
|
+
secure: true,
|
|
194
|
+
auth: {
|
|
195
|
+
user: this.credentials.receiverEmail,
|
|
196
|
+
pass: this.credentials.receiverPassword
|
|
197
|
+
},
|
|
198
|
+
logger: false
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
logImapConnection() {
|
|
202
|
+
log('IMAP connected to %s as %s', this.credentials.receiverImapHost ?? 'imap.gmail.com', this.credentials.receiverEmail);
|
|
203
|
+
}
|
|
204
|
+
buildSearchCriteria(filters) {
|
|
205
|
+
const criteria = {};
|
|
206
|
+
for (const filter of filters) {
|
|
207
|
+
switch (filter.type) {
|
|
208
|
+
case types_1.EmailFilterType.SUBJECT:
|
|
209
|
+
criteria.subject = filter.value;
|
|
210
|
+
break;
|
|
211
|
+
case types_1.EmailFilterType.FROM:
|
|
212
|
+
criteria.from = filter.value;
|
|
213
|
+
break;
|
|
214
|
+
case types_1.EmailFilterType.TO:
|
|
215
|
+
criteria.to = filter.value;
|
|
216
|
+
break;
|
|
217
|
+
case types_1.EmailFilterType.CONTENT:
|
|
218
|
+
criteria.body = filter.value;
|
|
219
|
+
break;
|
|
220
|
+
case types_1.EmailFilterType.SINCE:
|
|
221
|
+
criteria.since = filter.value;
|
|
222
|
+
break;
|
|
223
|
+
default: throw new Error(`Unknown email filter type: ${filter.type}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return criteria;
|
|
227
|
+
}
|
|
228
|
+
async fetchCandidates(client, filters, downloadDir) {
|
|
229
|
+
const searchCriteria = this.buildSearchCriteria(filters);
|
|
230
|
+
const candidates = [];
|
|
231
|
+
for await (const msg of client.fetch({ ...searchCriteria }, { source: true, envelope: true })) {
|
|
232
|
+
candidates.push(this.parseMessage(msg, downloadDir));
|
|
233
|
+
}
|
|
234
|
+
return candidates;
|
|
235
|
+
}
|
|
236
|
+
matchesAllFilters(email, filters, exact) {
|
|
237
|
+
return filters.every(filter => {
|
|
238
|
+
const filterValue = filter.value;
|
|
239
|
+
const fieldValue = this.getEmailField(email, filter.type);
|
|
240
|
+
if (exact)
|
|
241
|
+
return fieldValue === filterValue;
|
|
242
|
+
return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
getEmailField(email, filterType) {
|
|
246
|
+
switch (filterType) {
|
|
247
|
+
case types_1.EmailFilterType.SUBJECT: return email.subject;
|
|
248
|
+
case types_1.EmailFilterType.FROM: return email.from;
|
|
249
|
+
case types_1.EmailFilterType.TO: return '';
|
|
250
|
+
case types_1.EmailFilterType.CONTENT: return email.html || email.text;
|
|
251
|
+
default: return '';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
formatFilterSummary(filters) {
|
|
255
|
+
return filters.map(f => `${f.type}: ${f.value instanceof Date ? f.value.toISOString() : f.value}`).join(', ');
|
|
256
|
+
}
|
|
257
|
+
parseMessage(msg, downloadDir) {
|
|
258
|
+
const source = msg.source?.toString('utf-8') ?? '';
|
|
259
|
+
const envelope = msg.envelope;
|
|
260
|
+
const htmlBody = this.extractHtmlFromSource(source);
|
|
261
|
+
const textBody = this.extractTextFromSource(source);
|
|
262
|
+
const outputDir = downloadDir ?? path.join(os.tmpdir(), 'pw-emails');
|
|
263
|
+
if (!fs.existsSync(outputDir)) {
|
|
264
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
265
|
+
}
|
|
266
|
+
const sanitizedSubject = (envelope?.subject ?? 'email')
|
|
267
|
+
.replace(/[^a-zA-Z0-9-_]/g, '_')
|
|
268
|
+
.substring(0, 50);
|
|
269
|
+
const fileName = `${sanitizedSubject}-${Date.now()}.html`;
|
|
270
|
+
const filePath = path.join(outputDir, fileName);
|
|
271
|
+
const content = htmlBody || `<pre>${textBody}</pre>`;
|
|
272
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
273
|
+
log('Email downloaded to %s', filePath);
|
|
274
|
+
return {
|
|
275
|
+
filePath,
|
|
276
|
+
subject: envelope?.subject ?? '',
|
|
277
|
+
from: envelope?.from?.[0]?.address ?? '',
|
|
278
|
+
date: envelope?.date ?? new Date(),
|
|
279
|
+
html: htmlBody,
|
|
280
|
+
text: textBody
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
getSmtpTransport() {
|
|
284
|
+
if (!this.smtpTransport) {
|
|
285
|
+
this.smtpTransport = nodemailer.createTransport({
|
|
286
|
+
host: this.credentials.senderSmtpHost,
|
|
287
|
+
port: this.credentials.senderSmtpPort ?? 587,
|
|
288
|
+
secure: this.credentials.senderSmtpPort === 465,
|
|
289
|
+
auth: {
|
|
290
|
+
user: this.credentials.senderEmail,
|
|
291
|
+
pass: this.credentials.senderPassword
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return this.smtpTransport;
|
|
296
|
+
}
|
|
297
|
+
extractHtmlFromSource(source) {
|
|
298
|
+
const sectionMatch = source.match(/(Content-Type:\s*text\/html[^\r\n]*(?:\r?\n(?![\r\n])[^\r\n]*)*)\r?\n\r?\n([\s\S]*?)(?:\r?\n--|\r?\n\.\r?\n|$)/i);
|
|
299
|
+
if (sectionMatch) {
|
|
300
|
+
const headers = sectionMatch[1];
|
|
301
|
+
let content = sectionMatch[2];
|
|
302
|
+
if (/Content-Transfer-Encoding:\s*base64/i.test(headers)) {
|
|
303
|
+
try {
|
|
304
|
+
content = Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8');
|
|
305
|
+
}
|
|
306
|
+
catch { /* not base64 */ }
|
|
307
|
+
}
|
|
308
|
+
if (/Content-Transfer-Encoding:\s*quoted-printable/i.test(headers)) {
|
|
309
|
+
content = content
|
|
310
|
+
.replace(/=\r?\n/g, '')
|
|
311
|
+
.replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
312
|
+
}
|
|
313
|
+
return content;
|
|
314
|
+
}
|
|
315
|
+
return '';
|
|
316
|
+
}
|
|
317
|
+
extractTextFromSource(source) {
|
|
318
|
+
const sectionMatch = source.match(/(Content-Type:\s*text\/plain[^\r\n]*(?:\r?\n(?![\r\n])[^\r\n]*)*)\r?\n\r?\n([\s\S]*?)(?:\r?\n--|\r?\n\.\r?\n|$)/i);
|
|
319
|
+
if (sectionMatch) {
|
|
320
|
+
const headers = sectionMatch[1];
|
|
321
|
+
let content = sectionMatch[2];
|
|
322
|
+
if (/Content-Transfer-Encoding:\s*base64/i.test(headers)) {
|
|
323
|
+
try {
|
|
324
|
+
content = Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8');
|
|
325
|
+
}
|
|
326
|
+
catch { /* not base64 */ }
|
|
327
|
+
}
|
|
328
|
+
if (/Content-Transfer-Encoding:\s*quoted-printable/i.test(headers)) {
|
|
329
|
+
content = content
|
|
330
|
+
.replace(/=\r?\n/g, '')
|
|
331
|
+
.replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
332
|
+
}
|
|
333
|
+
return content;
|
|
334
|
+
}
|
|
335
|
+
return '';
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
exports.EmailClient = EmailClient;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EmailFilterType = exports.EmailClient = void 0;
|
|
4
|
+
var EmailClient_1 = require("./EmailClient");
|
|
5
|
+
Object.defineProperty(exports, "EmailClient", { enumerable: true, get: function () { return EmailClient_1.EmailClient; } });
|
|
6
|
+
var types_1 = require("./types");
|
|
7
|
+
Object.defineProperty(exports, "EmailFilterType", { enumerable: true, get: function () { return types_1.EmailFilterType; } });
|
package/dist/logger.d.ts
ADDED
package/dist/logger.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createLogger = createLogger;
|
|
7
|
+
const debug_1 = __importDefault(require("debug"));
|
|
8
|
+
/**
|
|
9
|
+
* Creates a namespaced debug logger for the email-client package.
|
|
10
|
+
* Enable with `DEBUG=email-client:*` environment variable.
|
|
11
|
+
*/
|
|
12
|
+
function createLogger(namespace) {
|
|
13
|
+
return (0, debug_1.default)(`email-client:${namespace}`);
|
|
14
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defines the type of filter to apply when searching for emails.
|
|
3
|
+
*/
|
|
4
|
+
export declare enum EmailFilterType {
|
|
5
|
+
/** Filter by email subject. */
|
|
6
|
+
SUBJECT = "subject",
|
|
7
|
+
/** Filter by sender address. */
|
|
8
|
+
FROM = "from",
|
|
9
|
+
/** Filter by recipient address. */
|
|
10
|
+
TO = "to",
|
|
11
|
+
/** Filter by email body content (HTML or plain text). */
|
|
12
|
+
CONTENT = "content",
|
|
13
|
+
/** Filter to only include emails received after a given date. Value must be a Date object. */
|
|
14
|
+
SINCE = "since"
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A single email search filter. Combine multiple filters in an array
|
|
18
|
+
* to narrow down the search.
|
|
19
|
+
*/
|
|
20
|
+
export interface EmailFilter {
|
|
21
|
+
/** The filter type to apply. */
|
|
22
|
+
type: EmailFilterType;
|
|
23
|
+
/** The value to filter by. Use a string for SUBJECT/FROM/TO/CONTENT, or a Date for SINCE. */
|
|
24
|
+
value: string | Date;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* SMTP and IMAP credentials for the email client.
|
|
28
|
+
*/
|
|
29
|
+
export interface EmailCredentials {
|
|
30
|
+
/** SMTP sender email address. */
|
|
31
|
+
senderEmail: string;
|
|
32
|
+
/** SMTP sender password or app password. */
|
|
33
|
+
senderPassword: string;
|
|
34
|
+
/** SMTP host (e.g. 'smtp-relay.sendinblue.com'). */
|
|
35
|
+
senderSmtpHost: string;
|
|
36
|
+
/** SMTP port. Defaults to 587. */
|
|
37
|
+
senderSmtpPort?: number;
|
|
38
|
+
/** IMAP receiver email address. */
|
|
39
|
+
receiverEmail: string;
|
|
40
|
+
/** IMAP receiver password or app password. */
|
|
41
|
+
receiverPassword: string;
|
|
42
|
+
/** IMAP host. Defaults to 'imap.gmail.com'. */
|
|
43
|
+
receiverImapHost?: string;
|
|
44
|
+
/** IMAP port. Defaults to 993. */
|
|
45
|
+
receiverImapPort?: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Options for sending an email.
|
|
49
|
+
* Provide `text` for plain-text, `html` for inline HTML, or `htmlFile` for an HTML template file.
|
|
50
|
+
*/
|
|
51
|
+
export interface EmailSendOptions {
|
|
52
|
+
/** Recipient email address. */
|
|
53
|
+
to: string;
|
|
54
|
+
/** Email subject line. */
|
|
55
|
+
subject: string;
|
|
56
|
+
/** Plain-text body. Used when neither `html` nor `htmlFile` is provided. */
|
|
57
|
+
text?: string;
|
|
58
|
+
/** Inline HTML body. Takes precedence over `text`. */
|
|
59
|
+
html?: string;
|
|
60
|
+
/** Path to an HTML file to use as the email body. Takes precedence over `html` and `text`. */
|
|
61
|
+
htmlFile?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Options for receiving (searching and downloading) an email via IMAP.
|
|
65
|
+
*/
|
|
66
|
+
export interface EmailReceiveOptions {
|
|
67
|
+
/** Array of filters to apply when searching for emails. All filters are combined (AND logic). */
|
|
68
|
+
filters: EmailFilter[];
|
|
69
|
+
/** IMAP folder to search. Defaults to 'INBOX'. */
|
|
70
|
+
folder?: string;
|
|
71
|
+
/** How long to poll for a matching email (ms). Defaults to 30000. */
|
|
72
|
+
waitTimeout?: number;
|
|
73
|
+
/** Interval between poll attempts (ms). Defaults to 3000. */
|
|
74
|
+
pollInterval?: number;
|
|
75
|
+
/** Directory to save downloaded email HTML. Defaults to os.tmpdir()/pw-emails. */
|
|
76
|
+
downloadDir?: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Represents a received email after download.
|
|
80
|
+
*/
|
|
81
|
+
export interface ReceivedEmail {
|
|
82
|
+
/** Local file path of the downloaded HTML. Open with `navigateTo('file://' + filePath)`. */
|
|
83
|
+
filePath: string;
|
|
84
|
+
/** Email subject. */
|
|
85
|
+
subject: string;
|
|
86
|
+
/** Sender address. */
|
|
87
|
+
from: string;
|
|
88
|
+
/** Date the email was sent. */
|
|
89
|
+
date: Date;
|
|
90
|
+
/** Raw HTML content (empty string if plain-text only). */
|
|
91
|
+
html: string;
|
|
92
|
+
/** Plain-text content. */
|
|
93
|
+
text: string;
|
|
94
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EmailFilterType = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Defines the type of filter to apply when searching for emails.
|
|
6
|
+
*/
|
|
7
|
+
var EmailFilterType;
|
|
8
|
+
(function (EmailFilterType) {
|
|
9
|
+
/** Filter by email subject. */
|
|
10
|
+
EmailFilterType["SUBJECT"] = "subject";
|
|
11
|
+
/** Filter by sender address. */
|
|
12
|
+
EmailFilterType["FROM"] = "from";
|
|
13
|
+
/** Filter by recipient address. */
|
|
14
|
+
EmailFilterType["TO"] = "to";
|
|
15
|
+
/** Filter by email body content (HTML or plain text). */
|
|
16
|
+
EmailFilterType["CONTENT"] = "content";
|
|
17
|
+
/** Filter to only include emails received after a given date. Value must be a Date object. */
|
|
18
|
+
EmailFilterType["SINCE"] = "since";
|
|
19
|
+
})(EmailFilterType || (exports.EmailFilterType = EmailFilterType = {}));
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@civitas-cerebrum/email-client",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A generic SMTP/IMAP email client for test automation. Send, receive, search, and clean emails with composable filters.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"clean": "rm -rf dist",
|
|
9
|
+
"build": "npm run clean && tsc",
|
|
10
|
+
"test": "npx playwright test",
|
|
11
|
+
"prepublishOnly": "npm run build"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"debug": "^4.4.3",
|
|
18
|
+
"imapflow": "^1.2.16",
|
|
19
|
+
"nodemailer": "^8.0.3"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@playwright/test": "^1.58.2",
|
|
23
|
+
"@types/debug": "^4.1.13",
|
|
24
|
+
"@types/node": "^20.0.0",
|
|
25
|
+
"@types/nodemailer": "^7.0.11",
|
|
26
|
+
"typescript": "^5.0.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/civitas-cerebrum/email-client.git"
|
|
35
|
+
},
|
|
36
|
+
"author": "Umut Ay Bora",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"keywords": [
|
|
39
|
+
"email",
|
|
40
|
+
"smtp",
|
|
41
|
+
"imap",
|
|
42
|
+
"nodemailer",
|
|
43
|
+
"test-automation",
|
|
44
|
+
"email-filter"
|
|
45
|
+
]
|
|
46
|
+
}
|