@builderbot/provider-email 1.3.15-alpha.8
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/LICENSE.md +21 -0
- package/README.md +254 -0
- package/dist/email/core.d.ts +77 -0
- package/dist/email/core.d.ts.map +1 -0
- package/dist/email/provider.d.ts +92 -0
- package/dist/email/provider.d.ts.map +1 -0
- package/dist/index.cjs +871 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/interface/email.d.ts +58 -0
- package/dist/interface/email.d.ts.map +1 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/parser.d.ts +71 -0
- package/dist/utils/parser.d.ts.map +1 -0
- package/package.json +51 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var bot = require('@builderbot/bot');
|
|
4
|
+
var promises = require('fs/promises');
|
|
5
|
+
var os = require('os');
|
|
6
|
+
var path = require('path');
|
|
7
|
+
var imapflow = require('imapflow');
|
|
8
|
+
var mailparser = require('mailparser');
|
|
9
|
+
var EventEmitter = require('node:events');
|
|
10
|
+
var nodemailer = require('nodemailer');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Class representing EmailCoreVendor, handles IMAP/SMTP operations.
|
|
14
|
+
* @extends EventEmitter
|
|
15
|
+
*/
|
|
16
|
+
class EmailCoreVendor extends EventEmitter {
|
|
17
|
+
constructor(config) {
|
|
18
|
+
super();
|
|
19
|
+
this.imapClient = null;
|
|
20
|
+
this.smtpTransporter = null;
|
|
21
|
+
this.isConnected = false;
|
|
22
|
+
this.reconnectAttempts = 0;
|
|
23
|
+
this.maxReconnectAttempts = 10;
|
|
24
|
+
this.reconnectDelay = 5000;
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.initializeSmtp();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Initialize SMTP transporter for sending emails
|
|
30
|
+
*/
|
|
31
|
+
initializeSmtp() {
|
|
32
|
+
try {
|
|
33
|
+
this.smtpTransporter = nodemailer.createTransport({
|
|
34
|
+
host: this.config.smtp.host,
|
|
35
|
+
port: this.config.smtp.port,
|
|
36
|
+
secure: this.config.smtp.secure ?? true,
|
|
37
|
+
auth: {
|
|
38
|
+
user: this.config.smtp.auth.user,
|
|
39
|
+
pass: this.config.smtp.auth.pass,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
console.log('[EmailProvider] SMTP transporter initialized');
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.error('[EmailProvider] Failed to initialize SMTP:', error);
|
|
46
|
+
this.emit('auth_failure', error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Connect to IMAP server and start listening for new emails
|
|
51
|
+
*/
|
|
52
|
+
async connect() {
|
|
53
|
+
try {
|
|
54
|
+
this.imapClient = new imapflow.ImapFlow({
|
|
55
|
+
host: this.config.imap.host,
|
|
56
|
+
port: this.config.imap.port,
|
|
57
|
+
secure: this.config.imap.secure ?? true,
|
|
58
|
+
auth: {
|
|
59
|
+
user: this.config.imap.auth.user,
|
|
60
|
+
pass: this.config.imap.auth.pass,
|
|
61
|
+
},
|
|
62
|
+
logger: false,
|
|
63
|
+
});
|
|
64
|
+
// Handle connection events
|
|
65
|
+
this.imapClient.on('error', (err) => {
|
|
66
|
+
console.error('[EmailProvider] IMAP error:', err);
|
|
67
|
+
this.emit('error', err);
|
|
68
|
+
this.handleDisconnect();
|
|
69
|
+
});
|
|
70
|
+
this.imapClient.on('close', () => {
|
|
71
|
+
console.log('[EmailProvider] IMAP connection closed');
|
|
72
|
+
this.isConnected = false;
|
|
73
|
+
this.handleDisconnect();
|
|
74
|
+
});
|
|
75
|
+
await this.imapClient.connect();
|
|
76
|
+
this.isConnected = true;
|
|
77
|
+
this.reconnectAttempts = 0;
|
|
78
|
+
console.log('[EmailProvider] Connected to IMAP server');
|
|
79
|
+
const host = {
|
|
80
|
+
email: this.config.imap.auth.user,
|
|
81
|
+
phone: this.config.imap.auth.user,
|
|
82
|
+
};
|
|
83
|
+
this.emit('host', host);
|
|
84
|
+
this.emit('ready');
|
|
85
|
+
// Start listening for new emails
|
|
86
|
+
await this.startIdleListener();
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.error('[EmailProvider] Failed to connect to IMAP:', error);
|
|
90
|
+
this.emit('auth_failure', error);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Handle disconnection and attempt reconnection
|
|
96
|
+
*/
|
|
97
|
+
async handleDisconnect() {
|
|
98
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
99
|
+
console.error('[EmailProvider] Max reconnection attempts reached');
|
|
100
|
+
this.emit('auth_failure', new Error('Max reconnection attempts reached'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.reconnectAttempts++;
|
|
104
|
+
const delay = this.reconnectDelay * this.reconnectAttempts;
|
|
105
|
+
console.log(`[EmailProvider] Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
|
|
106
|
+
setTimeout(async () => {
|
|
107
|
+
try {
|
|
108
|
+
await this.connect();
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
console.error('[EmailProvider] Reconnection failed:', error);
|
|
112
|
+
}
|
|
113
|
+
}, delay);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Start IMAP IDLE listener for real-time email notifications
|
|
117
|
+
*/
|
|
118
|
+
async startIdleListener() {
|
|
119
|
+
if (!this.imapClient || !this.isConnected)
|
|
120
|
+
return;
|
|
121
|
+
const mailbox = this.config.mailbox || 'INBOX';
|
|
122
|
+
try {
|
|
123
|
+
// Select the mailbox
|
|
124
|
+
const lock = await this.imapClient.getMailboxLock(mailbox);
|
|
125
|
+
try {
|
|
126
|
+
// Listen for new messages using EXISTS event
|
|
127
|
+
this.imapClient.on('exists', async (data) => {
|
|
128
|
+
if (data.count > data.prevCount) {
|
|
129
|
+
console.log(`[EmailProvider] New email detected in ${data.path}`);
|
|
130
|
+
await this.fetchNewEmails(data.prevCount + 1, data.count);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// Start IDLE mode
|
|
134
|
+
console.log(`[EmailProvider] Starting IDLE mode on ${mailbox}`);
|
|
135
|
+
// Keep the connection alive with IDLE
|
|
136
|
+
while (this.isConnected && this.imapClient) {
|
|
137
|
+
try {
|
|
138
|
+
await this.imapClient.idle();
|
|
139
|
+
}
|
|
140
|
+
catch (idleError) {
|
|
141
|
+
if (this.isConnected) {
|
|
142
|
+
console.error('[EmailProvider] IDLE error:', idleError);
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
lock.release();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
console.error('[EmailProvider] Failed to start IDLE listener:', error);
|
|
154
|
+
this.emit('error', error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Fetch new emails from a sequence range
|
|
159
|
+
*/
|
|
160
|
+
async fetchNewEmails(startSeq, endSeq) {
|
|
161
|
+
if (!this.imapClient || !this.isConnected)
|
|
162
|
+
return;
|
|
163
|
+
const mailbox = this.config.mailbox || 'INBOX';
|
|
164
|
+
try {
|
|
165
|
+
const lock = await this.imapClient.getMailboxLock(mailbox);
|
|
166
|
+
try {
|
|
167
|
+
for await (const message of this.imapClient.fetch(`${startSeq}:${endSeq}`, {
|
|
168
|
+
source: true,
|
|
169
|
+
uid: true,
|
|
170
|
+
})) {
|
|
171
|
+
try {
|
|
172
|
+
const parsed = await mailparser.simpleParser(message.source);
|
|
173
|
+
const emailContext = this.parseEmailToContext(parsed, message.uid);
|
|
174
|
+
if (emailContext) {
|
|
175
|
+
// Mark as read if configured
|
|
176
|
+
if (this.config.markAsRead !== false) {
|
|
177
|
+
await this.imapClient.messageFlagsAdd({ uid: message.uid }, ['\\Seen']);
|
|
178
|
+
}
|
|
179
|
+
this.emit('message', emailContext);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (parseError) {
|
|
183
|
+
console.error('[EmailProvider] Failed to parse email:', parseError);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
lock.release();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
console.error('[EmailProvider] Failed to fetch new emails:', error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Parse a mailparser ParsedMail object to EmailBotContext
|
|
197
|
+
*/
|
|
198
|
+
parseEmailToContext(parsed, uid) {
|
|
199
|
+
const fromAddress = this.extractAddress(parsed.from);
|
|
200
|
+
if (!fromAddress) {
|
|
201
|
+
console.warn('[EmailProvider] Email has no from address, skipping');
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
// Extract attachments
|
|
205
|
+
const attachments = (parsed.attachments || []).map((att) => ({
|
|
206
|
+
filename: att.filename || 'unnamed',
|
|
207
|
+
contentType: att.contentType,
|
|
208
|
+
size: att.size,
|
|
209
|
+
contentId: att.contentId,
|
|
210
|
+
content: att.content,
|
|
211
|
+
}));
|
|
212
|
+
// Determine if this is a reply
|
|
213
|
+
const isReply = !!(parsed.inReplyTo || (parsed.references && parsed.references.length > 0));
|
|
214
|
+
// Get thread ID from references
|
|
215
|
+
const threadId = parsed.references
|
|
216
|
+
? Array.isArray(parsed.references)
|
|
217
|
+
? parsed.references[0]
|
|
218
|
+
: parsed.references
|
|
219
|
+
: parsed.inReplyTo || parsed.messageId;
|
|
220
|
+
// Build body - generate special events for attachments
|
|
221
|
+
let body = parsed.text || '';
|
|
222
|
+
if (attachments.length > 0 && !body.trim()) {
|
|
223
|
+
// Email with only attachments
|
|
224
|
+
const hasMedia = attachments.some((a) => a.contentType.startsWith('image/') || a.contentType.startsWith('video/'));
|
|
225
|
+
const hasDocument = attachments.some((a) => a.contentType.startsWith('application/') || a.contentType.startsWith('text/'));
|
|
226
|
+
if (hasMedia) {
|
|
227
|
+
body = bot.utils.generateRefProvider('_event_media_');
|
|
228
|
+
}
|
|
229
|
+
else if (hasDocument) {
|
|
230
|
+
body = bot.utils.generateRefProvider('_event_document_');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const context = {
|
|
234
|
+
from: fromAddress.address,
|
|
235
|
+
name: fromAddress.name || fromAddress.address,
|
|
236
|
+
body: body,
|
|
237
|
+
subject: parsed.subject || '(no subject)',
|
|
238
|
+
messageId: parsed.messageId || `${uid}@${this.config.imap.host}`,
|
|
239
|
+
threadId: threadId,
|
|
240
|
+
inReplyTo: parsed.inReplyTo,
|
|
241
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
242
|
+
isReply: isReply,
|
|
243
|
+
html: parsed.html || undefined,
|
|
244
|
+
to: this.extractAddresses(parsed.to),
|
|
245
|
+
cc: parsed.cc ? this.extractAddresses(parsed.cc) : undefined,
|
|
246
|
+
date: parsed.date,
|
|
247
|
+
uid: uid,
|
|
248
|
+
};
|
|
249
|
+
return context;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Extract single address from AddressObject
|
|
253
|
+
*/
|
|
254
|
+
extractAddress(addressObj) {
|
|
255
|
+
if (!addressObj)
|
|
256
|
+
return null;
|
|
257
|
+
const obj = Array.isArray(addressObj) ? addressObj[0] : addressObj;
|
|
258
|
+
if (!obj || !obj.value || obj.value.length === 0)
|
|
259
|
+
return null;
|
|
260
|
+
const first = obj.value[0];
|
|
261
|
+
return {
|
|
262
|
+
address: first.address || '',
|
|
263
|
+
name: first.name || '',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Extract array of addresses from AddressObject
|
|
268
|
+
*/
|
|
269
|
+
extractAddresses(addressObj) {
|
|
270
|
+
if (!addressObj)
|
|
271
|
+
return [];
|
|
272
|
+
const objects = Array.isArray(addressObj) ? addressObj : [addressObj];
|
|
273
|
+
const addresses = [];
|
|
274
|
+
for (const obj of objects) {
|
|
275
|
+
if (obj && obj.value) {
|
|
276
|
+
for (const addr of obj.value) {
|
|
277
|
+
if (addr.address) {
|
|
278
|
+
addresses.push(addr.address);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return addresses;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Send an email via SMTP
|
|
287
|
+
*/
|
|
288
|
+
async sendEmail(to, subject, text, options) {
|
|
289
|
+
if (!this.smtpTransporter) {
|
|
290
|
+
throw new Error('SMTP transporter not initialized');
|
|
291
|
+
}
|
|
292
|
+
const fromEmail = this.config.fromEmail || this.config.smtp.auth.user;
|
|
293
|
+
const fromName = this.config.fromName || fromEmail;
|
|
294
|
+
const mailOptions = {
|
|
295
|
+
from: `"${fromName}" <${fromEmail}>`,
|
|
296
|
+
to: to,
|
|
297
|
+
subject: subject,
|
|
298
|
+
text: text,
|
|
299
|
+
};
|
|
300
|
+
// Add optional fields
|
|
301
|
+
if (options?.html) {
|
|
302
|
+
mailOptions.html = options.html;
|
|
303
|
+
}
|
|
304
|
+
if (options?.cc) {
|
|
305
|
+
mailOptions.cc = options.cc;
|
|
306
|
+
}
|
|
307
|
+
if (options?.bcc) {
|
|
308
|
+
mailOptions.bcc = options.bcc;
|
|
309
|
+
}
|
|
310
|
+
if (options?.replyTo) {
|
|
311
|
+
mailOptions.replyTo = options.replyTo;
|
|
312
|
+
}
|
|
313
|
+
if (options?.inReplyTo) {
|
|
314
|
+
mailOptions.inReplyTo = options.inReplyTo;
|
|
315
|
+
}
|
|
316
|
+
if (options?.references) {
|
|
317
|
+
mailOptions.references = Array.isArray(options.references)
|
|
318
|
+
? options.references.join(' ')
|
|
319
|
+
: options.references;
|
|
320
|
+
}
|
|
321
|
+
if (options?.attachments) {
|
|
322
|
+
mailOptions.attachments = options.attachments.map((att) => ({
|
|
323
|
+
filename: att.filename,
|
|
324
|
+
path: att.path,
|
|
325
|
+
content: att.content,
|
|
326
|
+
contentType: att.contentType,
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const info = await this.smtpTransporter.sendMail(mailOptions);
|
|
331
|
+
console.log(`[EmailProvider] Email sent: ${info.messageId}`);
|
|
332
|
+
return { messageId: info.messageId };
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
console.error('[EmailProvider] Failed to send email:', error);
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Reply to an existing email thread
|
|
341
|
+
*/
|
|
342
|
+
async replyToEmail(originalContext, text, options) {
|
|
343
|
+
// Build references chain
|
|
344
|
+
const references = [];
|
|
345
|
+
if (originalContext.threadId) {
|
|
346
|
+
references.push(originalContext.threadId);
|
|
347
|
+
}
|
|
348
|
+
if (originalContext.messageId && originalContext.messageId !== originalContext.threadId) {
|
|
349
|
+
references.push(originalContext.messageId);
|
|
350
|
+
}
|
|
351
|
+
// Prepare subject with Re: prefix if not already present
|
|
352
|
+
let subject = originalContext.subject;
|
|
353
|
+
if (!subject.toLowerCase().startsWith('re:')) {
|
|
354
|
+
subject = `Re: ${subject}`;
|
|
355
|
+
}
|
|
356
|
+
return this.sendEmail(originalContext.from, subject, text, {
|
|
357
|
+
...options,
|
|
358
|
+
inReplyTo: originalContext.messageId,
|
|
359
|
+
references: references,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Download attachment content
|
|
364
|
+
*/
|
|
365
|
+
async downloadAttachment(ctx, attachmentIndex) {
|
|
366
|
+
if (!ctx.attachments || attachmentIndex >= ctx.attachments.length) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const attachment = ctx.attachments[attachmentIndex];
|
|
370
|
+
if (attachment.content) {
|
|
371
|
+
return attachment.content;
|
|
372
|
+
}
|
|
373
|
+
// Attachment content should already be in memory from parsing
|
|
374
|
+
console.warn('[EmailProvider] Attachment content not available');
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Disconnect from IMAP server
|
|
379
|
+
*/
|
|
380
|
+
async disconnect() {
|
|
381
|
+
this.isConnected = false;
|
|
382
|
+
if (this.imapClient) {
|
|
383
|
+
try {
|
|
384
|
+
await this.imapClient.logout();
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
console.error('[EmailProvider] Error during logout:', error);
|
|
388
|
+
}
|
|
389
|
+
this.imapClient = null;
|
|
390
|
+
}
|
|
391
|
+
if (this.smtpTransporter) {
|
|
392
|
+
this.smtpTransporter.close();
|
|
393
|
+
this.smtpTransporter = null;
|
|
394
|
+
}
|
|
395
|
+
console.log('[EmailProvider] Disconnected');
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Check if connected to IMAP server
|
|
399
|
+
*/
|
|
400
|
+
isImapConnected() {
|
|
401
|
+
return this.isConnected && this.imapClient !== null;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Verify SMTP connection
|
|
405
|
+
*/
|
|
406
|
+
async verifySmtp() {
|
|
407
|
+
if (!this.smtpTransporter)
|
|
408
|
+
return false;
|
|
409
|
+
try {
|
|
410
|
+
await this.smtpTransporter.verify();
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Email Provider for BuilderBot
|
|
421
|
+
* Supports receiving emails via IMAP (with IDLE) and sending via SMTP
|
|
422
|
+
* @extends ProviderClass
|
|
423
|
+
*/
|
|
424
|
+
class EmailProvider extends bot.ProviderClass {
|
|
425
|
+
constructor(args) {
|
|
426
|
+
super();
|
|
427
|
+
/**
|
|
428
|
+
* Index home endpoint
|
|
429
|
+
*/
|
|
430
|
+
this.indexHome = (_, res) => {
|
|
431
|
+
res.end('Email Provider running');
|
|
432
|
+
};
|
|
433
|
+
/**
|
|
434
|
+
* Webhook handler for external email notifications (optional)
|
|
435
|
+
*/
|
|
436
|
+
this.webhookHandler = (req, res) => {
|
|
437
|
+
// This can be used for external email webhook integrations
|
|
438
|
+
const body = req.body;
|
|
439
|
+
console.log('[EmailProvider] Webhook received:', body);
|
|
440
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
441
|
+
};
|
|
442
|
+
/**
|
|
443
|
+
* Map vendor events to provider events
|
|
444
|
+
*/
|
|
445
|
+
this.busEvents = () => [
|
|
446
|
+
{
|
|
447
|
+
event: 'auth_failure',
|
|
448
|
+
func: (payload) => this.emit('auth_failure', payload),
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
event: 'ready',
|
|
452
|
+
func: () => this.emit('ready', true),
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
event: 'message',
|
|
456
|
+
func: (payload) => {
|
|
457
|
+
this.emit('message', payload);
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
event: 'host',
|
|
462
|
+
func: (payload) => {
|
|
463
|
+
this.emit('host', payload);
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
event: 'error',
|
|
468
|
+
func: (payload) => {
|
|
469
|
+
console.error('[EmailProvider] Error:', payload);
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
];
|
|
473
|
+
// Validate required configuration
|
|
474
|
+
if (!args.imap) {
|
|
475
|
+
throw new Error('IMAP configuration is required');
|
|
476
|
+
}
|
|
477
|
+
if (!args.smtp) {
|
|
478
|
+
throw new Error('SMTP configuration is required');
|
|
479
|
+
}
|
|
480
|
+
if (!args.imap.host || !args.imap.auth?.user || !args.imap.auth?.pass) {
|
|
481
|
+
throw new Error('IMAP host and authentication are required');
|
|
482
|
+
}
|
|
483
|
+
if (!args.smtp.host || !args.smtp.auth?.user || !args.smtp.auth?.pass) {
|
|
484
|
+
throw new Error('SMTP host and authentication are required');
|
|
485
|
+
}
|
|
486
|
+
this.globalVendorArgs = {
|
|
487
|
+
name: 'email-bot',
|
|
488
|
+
port: 3000,
|
|
489
|
+
writeMyself: 'none',
|
|
490
|
+
mailbox: 'INBOX',
|
|
491
|
+
markAsRead: true,
|
|
492
|
+
...args,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Initialize the email vendor (IMAP/SMTP connections)
|
|
497
|
+
*/
|
|
498
|
+
async initVendor() {
|
|
499
|
+
const vendor = new EmailCoreVendor(this.globalVendorArgs);
|
|
500
|
+
this.vendor = vendor;
|
|
501
|
+
// Connect to IMAP server
|
|
502
|
+
await vendor.connect();
|
|
503
|
+
return vendor;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Called before HTTP server initialization
|
|
507
|
+
*/
|
|
508
|
+
beforeHttpServerInit() {
|
|
509
|
+
this.server = this.server
|
|
510
|
+
.use((req, _, next) => {
|
|
511
|
+
req['globalVendorArgs'] = this.globalVendorArgs;
|
|
512
|
+
return next();
|
|
513
|
+
})
|
|
514
|
+
.get('/', this.indexHome)
|
|
515
|
+
.post('/webhook', this.webhookHandler);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Called after HTTP server initialization
|
|
519
|
+
*/
|
|
520
|
+
afterHttpServerInit() { }
|
|
521
|
+
/**
|
|
522
|
+
* Send an email message
|
|
523
|
+
* @param to - Recipient email address
|
|
524
|
+
* @param message - Email body content
|
|
525
|
+
* @param options - Send options (subject, attachments, etc.)
|
|
526
|
+
*/
|
|
527
|
+
async sendMessage(to, message, options) {
|
|
528
|
+
const emailOptions = options;
|
|
529
|
+
// Default subject if not provided
|
|
530
|
+
const subject = emailOptions?.subject || 'Message from Bot';
|
|
531
|
+
// Check if we're replying to an existing thread
|
|
532
|
+
if (emailOptions?.inReplyTo) {
|
|
533
|
+
return this.vendor.sendEmail(to, subject, message, emailOptions);
|
|
534
|
+
}
|
|
535
|
+
// Check for media/attachments
|
|
536
|
+
if (options?.media) {
|
|
537
|
+
return this.sendMedia(to, message, options.media, emailOptions);
|
|
538
|
+
}
|
|
539
|
+
return this.vendor.sendEmail(to, subject, message, emailOptions);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Send an email with media attachment
|
|
543
|
+
* @param to - Recipient email address
|
|
544
|
+
* @param message - Email body content
|
|
545
|
+
* @param mediaPath - Path to media file
|
|
546
|
+
* @param options - Additional email options
|
|
547
|
+
*/
|
|
548
|
+
async sendMedia(to, message, mediaPath, options) {
|
|
549
|
+
const subject = options?.subject || 'Message with attachment';
|
|
550
|
+
const attachments = [
|
|
551
|
+
{
|
|
552
|
+
filename: mediaPath.split('/').pop() || 'attachment',
|
|
553
|
+
path: mediaPath,
|
|
554
|
+
},
|
|
555
|
+
];
|
|
556
|
+
return this.vendor.sendEmail(to, subject, message, {
|
|
557
|
+
...options,
|
|
558
|
+
attachments: [...(options?.attachments || []), ...attachments],
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Reply to an existing email thread
|
|
563
|
+
* @param ctx - Original email context
|
|
564
|
+
* @param message - Reply message content
|
|
565
|
+
* @param options - Additional email options
|
|
566
|
+
*/
|
|
567
|
+
async reply(ctx, message, options) {
|
|
568
|
+
return this.vendor.replyToEmail(ctx, message, options);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Save an attachment from an email to disk
|
|
572
|
+
* @param ctx - Email context containing attachments
|
|
573
|
+
* @param options - Save options (path, attachment index)
|
|
574
|
+
*/
|
|
575
|
+
async saveFile(ctx, options) {
|
|
576
|
+
try {
|
|
577
|
+
const emailCtx = ctx;
|
|
578
|
+
if (!emailCtx.attachments || emailCtx.attachments.length === 0) {
|
|
579
|
+
throw new Error('No attachments in email');
|
|
580
|
+
}
|
|
581
|
+
const attachmentIndex = options?.attachmentIndex ?? 0;
|
|
582
|
+
const attachment = emailCtx.attachments[attachmentIndex];
|
|
583
|
+
if (!attachment) {
|
|
584
|
+
throw new Error(`Attachment at index ${attachmentIndex} not found`);
|
|
585
|
+
}
|
|
586
|
+
if (!attachment.content) {
|
|
587
|
+
throw new Error('Attachment content not available');
|
|
588
|
+
}
|
|
589
|
+
const savePath = options?.path ?? os.tmpdir();
|
|
590
|
+
const fileName = `${Date.now()}-${attachment.filename}`;
|
|
591
|
+
const filePath = path.join(savePath, fileName);
|
|
592
|
+
await promises.writeFile(filePath, attachment.content);
|
|
593
|
+
return path.resolve(filePath);
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
console.error('[EmailProvider] Error saving file:', error);
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Get all attachments from an email
|
|
602
|
+
* @param ctx - Email context
|
|
603
|
+
*/
|
|
604
|
+
getAttachments(ctx) {
|
|
605
|
+
return ctx.attachments || [];
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Check if the email is a reply
|
|
609
|
+
* @param ctx - Email context
|
|
610
|
+
*/
|
|
611
|
+
isReply(ctx) {
|
|
612
|
+
return ctx.isReply;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Get the thread ID from an email
|
|
616
|
+
* @param ctx - Email context
|
|
617
|
+
*/
|
|
618
|
+
getThreadId(ctx) {
|
|
619
|
+
return ctx.threadId;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Disconnect the email provider
|
|
623
|
+
*/
|
|
624
|
+
async disconnect() {
|
|
625
|
+
if (this.vendor) {
|
|
626
|
+
await this.vendor.disconnect();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Email parsing utilities
|
|
633
|
+
*/
|
|
634
|
+
/**
|
|
635
|
+
* Extract email address from a string that might contain name and email
|
|
636
|
+
* e.g., "John Doe <john@example.com>" -> "john@example.com"
|
|
637
|
+
*/
|
|
638
|
+
function extractEmailAddress(input) {
|
|
639
|
+
if (!input)
|
|
640
|
+
return '';
|
|
641
|
+
// Check if it contains angle brackets
|
|
642
|
+
const match = input.match(/<([^>]+)>/);
|
|
643
|
+
if (match) {
|
|
644
|
+
return match[1].trim().toLowerCase();
|
|
645
|
+
}
|
|
646
|
+
// Return as-is if it looks like an email
|
|
647
|
+
const trimmed = input.trim().toLowerCase();
|
|
648
|
+
if (isValidEmail(trimmed)) {
|
|
649
|
+
return trimmed;
|
|
650
|
+
}
|
|
651
|
+
return trimmed;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Extract name from email string
|
|
655
|
+
* e.g., "John Doe <john@example.com>" -> "John Doe"
|
|
656
|
+
*/
|
|
657
|
+
function extractEmailName(input) {
|
|
658
|
+
if (!input)
|
|
659
|
+
return '';
|
|
660
|
+
// Check if it contains angle brackets
|
|
661
|
+
const bracketIndex = input.indexOf('<');
|
|
662
|
+
if (bracketIndex > 0) {
|
|
663
|
+
return input
|
|
664
|
+
.substring(0, bracketIndex)
|
|
665
|
+
.trim()
|
|
666
|
+
.replace(/^["']|["']$/g, '');
|
|
667
|
+
}
|
|
668
|
+
return '';
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Validate email address format
|
|
672
|
+
*/
|
|
673
|
+
function isValidEmail(email) {
|
|
674
|
+
if (!email)
|
|
675
|
+
return false;
|
|
676
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
677
|
+
return emailRegex.test(email);
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Clean and normalize email address
|
|
681
|
+
*/
|
|
682
|
+
function cleanEmail(email) {
|
|
683
|
+
return extractEmailAddress(email).toLowerCase().trim();
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Parse email list (comma or semicolon separated)
|
|
687
|
+
*/
|
|
688
|
+
function parseEmailList(input) {
|
|
689
|
+
if (!input)
|
|
690
|
+
return [];
|
|
691
|
+
return input
|
|
692
|
+
.split(/[,;]/)
|
|
693
|
+
.map((email) => extractEmailAddress(email))
|
|
694
|
+
.filter((email) => isValidEmail(email));
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Format email address with optional name
|
|
698
|
+
*/
|
|
699
|
+
function formatEmailAddress(email, name) {
|
|
700
|
+
if (name) {
|
|
701
|
+
return `"${name}" <${email}>`;
|
|
702
|
+
}
|
|
703
|
+
return email;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Extract plain text from HTML email content
|
|
707
|
+
* Basic implementation - strips HTML tags
|
|
708
|
+
*/
|
|
709
|
+
function htmlToText(html) {
|
|
710
|
+
if (!html)
|
|
711
|
+
return '';
|
|
712
|
+
return (html
|
|
713
|
+
// Remove script and style tags with content
|
|
714
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
715
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
716
|
+
// Replace common block elements with newlines
|
|
717
|
+
.replace(/<\/?(div|p|br|h[1-6]|li|tr)[^>]*>/gi, '\n')
|
|
718
|
+
// Remove remaining HTML tags
|
|
719
|
+
.replace(/<[^>]+>/g, '')
|
|
720
|
+
// Decode common HTML entities
|
|
721
|
+
.replace(/ /gi, ' ')
|
|
722
|
+
.replace(/&/gi, '&')
|
|
723
|
+
.replace(/</gi, '<')
|
|
724
|
+
.replace(/>/gi, '>')
|
|
725
|
+
.replace(/"/gi, '"')
|
|
726
|
+
.replace(/'/gi, "'")
|
|
727
|
+
// Clean up whitespace
|
|
728
|
+
.replace(/\n\s*\n/g, '\n\n')
|
|
729
|
+
.trim());
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Check if content is likely HTML
|
|
733
|
+
*/
|
|
734
|
+
function isHtml(content) {
|
|
735
|
+
if (!content)
|
|
736
|
+
return false;
|
|
737
|
+
return /<[a-z][\s\S]*>/i.test(content);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Extract thread ID from References or In-Reply-To headers
|
|
741
|
+
*/
|
|
742
|
+
function extractThreadId(references, inReplyTo) {
|
|
743
|
+
// Try references first (get the first/root message)
|
|
744
|
+
if (references) {
|
|
745
|
+
if (Array.isArray(references) && references.length > 0) {
|
|
746
|
+
return references[0];
|
|
747
|
+
}
|
|
748
|
+
if (typeof references === 'string') {
|
|
749
|
+
const refs = references.split(/\s+/).filter(Boolean);
|
|
750
|
+
if (refs.length > 0)
|
|
751
|
+
return refs[0];
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// Fall back to In-Reply-To
|
|
755
|
+
if (inReplyTo) {
|
|
756
|
+
return inReplyTo;
|
|
757
|
+
}
|
|
758
|
+
return undefined;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Check if email subject indicates a reply
|
|
762
|
+
*/
|
|
763
|
+
function isReplySubject(subject) {
|
|
764
|
+
if (!subject)
|
|
765
|
+
return false;
|
|
766
|
+
const replyPrefixes = ['re:', 'r:', 'aw:', 'sv:', 'antw:', 'odp:'];
|
|
767
|
+
const lowerSubject = subject.toLowerCase().trim();
|
|
768
|
+
return replyPrefixes.some((prefix) => lowerSubject.startsWith(prefix));
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Strip reply prefixes from subject
|
|
772
|
+
*/
|
|
773
|
+
function stripReplyPrefix(subject) {
|
|
774
|
+
if (!subject)
|
|
775
|
+
return '';
|
|
776
|
+
return subject.replace(/^(re:|r:|aw:|sv:|antw:|odp:)\s*/i, '').trim();
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Add reply prefix to subject if not present
|
|
780
|
+
*/
|
|
781
|
+
function addReplyPrefix(subject) {
|
|
782
|
+
if (!subject)
|
|
783
|
+
return 'Re:';
|
|
784
|
+
if (isReplySubject(subject))
|
|
785
|
+
return subject;
|
|
786
|
+
return `Re: ${subject}`;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Generate a unique message ID
|
|
790
|
+
*/
|
|
791
|
+
function generateMessageId(domain) {
|
|
792
|
+
const timestamp = Date.now().toString(36);
|
|
793
|
+
const random = Math.random().toString(36).substring(2, 10);
|
|
794
|
+
return `<${timestamp}.${random}@${domain}>`;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Parse MIME content type
|
|
798
|
+
*/
|
|
799
|
+
function parseMimeType(contentType) {
|
|
800
|
+
if (!contentType) {
|
|
801
|
+
return { type: 'text', subtype: 'plain', parameters: {} };
|
|
802
|
+
}
|
|
803
|
+
const parts = contentType.split(';');
|
|
804
|
+
const [type, subtype] = (parts[0] || 'text/plain').split('/');
|
|
805
|
+
const parameters = {};
|
|
806
|
+
for (let i = 1; i < parts.length; i++) {
|
|
807
|
+
const param = parts[i].trim();
|
|
808
|
+
const eqIndex = param.indexOf('=');
|
|
809
|
+
if (eqIndex > 0) {
|
|
810
|
+
const key = param.substring(0, eqIndex).trim().toLowerCase();
|
|
811
|
+
let value = param.substring(eqIndex + 1).trim();
|
|
812
|
+
// Remove quotes
|
|
813
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
814
|
+
value = value.slice(1, -1);
|
|
815
|
+
}
|
|
816
|
+
parameters[key] = value;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
type: type?.toLowerCase() || 'text',
|
|
821
|
+
subtype: subtype?.toLowerCase() || 'plain',
|
|
822
|
+
parameters,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Get file extension from MIME type
|
|
827
|
+
*/
|
|
828
|
+
function mimeToExtension(mimeType) {
|
|
829
|
+
const mimeMap = {
|
|
830
|
+
'text/plain': 'txt',
|
|
831
|
+
'text/html': 'html',
|
|
832
|
+
'text/css': 'css',
|
|
833
|
+
'text/javascript': 'js',
|
|
834
|
+
'application/json': 'json',
|
|
835
|
+
'application/pdf': 'pdf',
|
|
836
|
+
'application/zip': 'zip',
|
|
837
|
+
'application/xml': 'xml',
|
|
838
|
+
'image/jpeg': 'jpg',
|
|
839
|
+
'image/png': 'png',
|
|
840
|
+
'image/gif': 'gif',
|
|
841
|
+
'image/webp': 'webp',
|
|
842
|
+
'image/svg+xml': 'svg',
|
|
843
|
+
'audio/mpeg': 'mp3',
|
|
844
|
+
'audio/wav': 'wav',
|
|
845
|
+
'audio/ogg': 'ogg',
|
|
846
|
+
'video/mp4': 'mp4',
|
|
847
|
+
'video/webm': 'webm',
|
|
848
|
+
'video/quicktime': 'mov',
|
|
849
|
+
};
|
|
850
|
+
const { type, subtype } = parseMimeType(mimeType);
|
|
851
|
+
const fullType = `${type}/${subtype}`;
|
|
852
|
+
return mimeMap[fullType] || subtype || 'bin';
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
exports.EmailCoreVendor = EmailCoreVendor;
|
|
856
|
+
exports.EmailProvider = EmailProvider;
|
|
857
|
+
exports.addReplyPrefix = addReplyPrefix;
|
|
858
|
+
exports.cleanEmail = cleanEmail;
|
|
859
|
+
exports.extractEmailAddress = extractEmailAddress;
|
|
860
|
+
exports.extractEmailName = extractEmailName;
|
|
861
|
+
exports.extractThreadId = extractThreadId;
|
|
862
|
+
exports.formatEmailAddress = formatEmailAddress;
|
|
863
|
+
exports.generateMessageId = generateMessageId;
|
|
864
|
+
exports.htmlToText = htmlToText;
|
|
865
|
+
exports.isHtml = isHtml;
|
|
866
|
+
exports.isReplySubject = isReplySubject;
|
|
867
|
+
exports.isValidEmail = isValidEmail;
|
|
868
|
+
exports.mimeToExtension = mimeToExtension;
|
|
869
|
+
exports.parseEmailList = parseEmailList;
|
|
870
|
+
exports.parseMimeType = parseMimeType;
|
|
871
|
+
exports.stripReplyPrefix = stripReplyPrefix;
|