@zhin.js/adapter-email 1.0.1 → 1.1.0
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/CHANGELOG.md +374 -0
- package/README.md +50 -102
- package/adapters/email.js +25 -0
- package/adapters/email.ts +35 -0
- package/lib/client.d.ts +23 -0
- package/lib/client.js +29 -0
- package/lib/endpoint.d.ts +27 -32
- package/lib/endpoint.js +284 -317
- package/lib/index.d.ts +4 -10
- package/lib/index.js +4 -23
- package/lib/protocol.d.ts +138 -0
- package/lib/protocol.js +245 -0
- package/lib/transport.d.ts +32 -0
- package/lib/transport.js +27 -0
- package/package.json +45 -12
- package/plugin.js +8 -0
- package/schema.json +169 -0
- package/src/client.ts +40 -0
- package/src/endpoint.ts +334 -390
- package/src/index.ts +35 -28
- package/src/protocol.ts +384 -0
- package/src/transport.ts +65 -0
- package/lib/adapter.d.ts +0 -12
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -15
- package/lib/adapter.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -49
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/src/adapter.ts +0 -18
- package/src/types.ts +0 -52
- /package/{skills/email/SKILL.md → agent/skills/email.md} +0 -0
package/lib/endpoint.js
CHANGED
|
@@ -1,368 +1,335 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* EmailEndpoint — lifecycle, SMTP outbound, IMAP inbound polling.
|
|
3
4
|
*/
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import { simpleParser } from
|
|
7
|
-
import { formatCompact,
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { simpleParser } from 'mailparser';
|
|
8
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
9
|
+
import { emailInboundConversation, formatInboundContent, formatInboundSegments, formatOutboundMail, parseEmailMessage, senderDisplayName, } from './protocol.js';
|
|
10
|
+
import { defaultCreateImap, defaultCreateSmtp, } from './transport.js';
|
|
11
|
+
import { EmailClient } from './client.js';
|
|
12
|
+
/**
|
|
13
|
+
* Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
|
|
14
|
+
* 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
|
|
15
|
+
*/
|
|
16
|
+
export class EmailEndpoint extends Endpoint {
|
|
17
|
+
client;
|
|
18
|
+
#logger;
|
|
19
|
+
#options;
|
|
20
|
+
#smtp = null;
|
|
21
|
+
#imap = null;
|
|
22
|
+
#checkTimer = null;
|
|
23
|
+
#reconnectTimer = null;
|
|
24
|
+
#reconnectAttempts = 0;
|
|
25
|
+
#checking = false;
|
|
26
|
+
#open = false;
|
|
27
|
+
#started = false;
|
|
28
|
+
constructor(options) {
|
|
25
29
|
super();
|
|
26
|
-
this
|
|
27
|
-
this
|
|
28
|
-
|
|
29
|
-
this.$config.imap.checkInterval = this.$config.imap.checkInterval || 60000; // 1分钟
|
|
30
|
-
this.$config.imap.mailbox = this.$config.imap.mailbox || 'INBOX';
|
|
31
|
-
this.$config.imap.markSeen = this.$config.imap.markSeen !== false;
|
|
32
|
-
if (this.$config.attachments?.enabled) {
|
|
33
|
-
this.$config.attachments.downloadPath = this.$config.attachments.downloadPath || './downloads/email';
|
|
34
|
-
this.$config.attachments.maxFileSize = this.$config.attachments.maxFileSize || 10 * 1024 * 1024; // 10MB
|
|
35
|
-
}
|
|
30
|
+
this.#logger = getAdapterLogger('email', options.config.id);
|
|
31
|
+
this.#options = options;
|
|
32
|
+
this.client = new EmailClient(() => this.#smtp, () => this.#imap);
|
|
36
33
|
}
|
|
37
|
-
async
|
|
34
|
+
async start() {
|
|
35
|
+
if (this.#started)
|
|
36
|
+
return;
|
|
37
|
+
this.#started = true;
|
|
38
|
+
const { smtp, imap, id } = this.#options.config;
|
|
38
39
|
try {
|
|
39
|
-
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
auth: this.$config.smtp.auth
|
|
45
|
-
});
|
|
46
|
-
// 验证 SMTP 连接
|
|
47
|
-
await this.smtpTransporter.verify();
|
|
48
|
-
this.logger.info(formatCompact({ endpoint: this.$id, mode: "smtp" }));
|
|
49
|
-
// 初始化 IMAP 连接
|
|
50
|
-
this.imapConnection = new Imap({
|
|
51
|
-
user: this.$config.imap.user,
|
|
52
|
-
password: this.$config.imap.password,
|
|
53
|
-
host: this.$config.imap.host,
|
|
54
|
-
port: this.$config.imap.port,
|
|
55
|
-
tls: this.$config.imap.tls
|
|
56
|
-
});
|
|
57
|
-
// 设置 IMAP 事件监听
|
|
58
|
-
this.setupImapListeners();
|
|
59
|
-
// 连接 IMAP
|
|
40
|
+
this.#smtp = await (this.#options.createSmtp?.(smtp) ?? defaultCreateSmtp(smtp));
|
|
41
|
+
await this.#smtp.verify();
|
|
42
|
+
this.#logger.debug(formatCompact({ mode: 'smtp' }));
|
|
43
|
+
this.#imap = this.#options.createImap?.(imap) ?? defaultCreateImap(imap);
|
|
44
|
+
this.#setupImapListeners(this.#imap);
|
|
60
45
|
await new Promise((resolve, reject) => {
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
|
|
46
|
+
this.#imap.once('ready', () => {
|
|
47
|
+
void this.#emitPlatformEvent('imap.ready', Object.freeze({}));
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
this.#imap.once('error', (error) => reject(error));
|
|
51
|
+
this.#imap.connect();
|
|
64
52
|
});
|
|
65
|
-
this
|
|
66
|
-
|
|
67
|
-
this
|
|
68
|
-
this.$connected = true;
|
|
53
|
+
this.#logger.debug(formatCompact({ mode: 'imap' }));
|
|
54
|
+
this.#reconnectAttempts = 0;
|
|
55
|
+
this.#startEmailCheck();
|
|
69
56
|
}
|
|
70
57
|
catch (error) {
|
|
71
|
-
this.
|
|
58
|
+
await this.stop();
|
|
59
|
+
this.#logger.error('Failed to connect email services:', error);
|
|
72
60
|
throw error;
|
|
73
61
|
}
|
|
74
62
|
}
|
|
75
|
-
|
|
76
|
-
this
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
63
|
+
open() {
|
|
64
|
+
this.#open = true;
|
|
65
|
+
}
|
|
66
|
+
close() {
|
|
67
|
+
this.#open = false;
|
|
68
|
+
}
|
|
69
|
+
async stop() {
|
|
70
|
+
this.#open = false;
|
|
71
|
+
// 先复位 #started,避免 imap.end() 触发的 'end' 事件又武装重连定时器
|
|
72
|
+
this.#started = false;
|
|
73
|
+
if (this.#checkTimer) {
|
|
74
|
+
clearInterval(this.#checkTimer);
|
|
75
|
+
this.#checkTimer = null;
|
|
76
|
+
}
|
|
77
|
+
if (this.#reconnectTimer) {
|
|
78
|
+
clearTimeout(this.#reconnectTimer);
|
|
79
|
+
this.#reconnectTimer = null;
|
|
81
80
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
if (this.#imap) {
|
|
82
|
+
try {
|
|
83
|
+
this.#imap.end();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* ignore */
|
|
87
|
+
}
|
|
88
|
+
this.#imap = null;
|
|
86
89
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
if (this.#smtp) {
|
|
91
|
+
try {
|
|
92
|
+
this.#smtp.close();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* ignore */
|
|
96
|
+
}
|
|
97
|
+
this.#smtp = null;
|
|
91
98
|
}
|
|
92
|
-
this
|
|
99
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
93
100
|
}
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
async send({ conversation, payload }) {
|
|
102
|
+
const target = conversation.id;
|
|
103
|
+
const mailOptions = formatOutboundMail(payload, {
|
|
104
|
+
from: this.#options.config.smtp.auth.user,
|
|
105
|
+
to: target,
|
|
106
|
+
});
|
|
107
|
+
const info = await this.client.sendMail(mailOptions);
|
|
108
|
+
this.#logger.debug(formatCompact({ op: 'email_send', target, messageId: info.messageId }));
|
|
109
|
+
return info.messageId || '';
|
|
110
|
+
}
|
|
111
|
+
/** Test / internal: admit a parsed mail when the endpoint is open. */
|
|
112
|
+
admit(email) {
|
|
113
|
+
if (!this.#open)
|
|
96
114
|
return;
|
|
97
|
-
this
|
|
98
|
-
|
|
99
|
-
this.
|
|
115
|
+
void this.#emitPlatformEvent('mail', email);
|
|
116
|
+
void this.#admitWithAttachments(email).catch((err) => {
|
|
117
|
+
this.#logger.warn(formatCompact({
|
|
118
|
+
op: 'email_gateway_receive_failed',
|
|
119
|
+
target: email.from,
|
|
120
|
+
error: err instanceof Error ? err.message : String(err),
|
|
121
|
+
}));
|
|
100
122
|
});
|
|
101
|
-
|
|
102
|
-
|
|
123
|
+
}
|
|
124
|
+
async #admitWithAttachments(email) {
|
|
125
|
+
const savedAttachments = await this.#downloadAttachments(email);
|
|
126
|
+
const content = formatInboundContent(email);
|
|
127
|
+
const sender = email.from;
|
|
128
|
+
const conversation = emailInboundConversation(String(this.#options.id), email);
|
|
129
|
+
await this.emit('message.receive', {
|
|
130
|
+
conversation,
|
|
131
|
+
...(email.messageId ? { message: { conversation, id: email.messageId } } : {}),
|
|
132
|
+
content,
|
|
133
|
+
segments: formatInboundSegments(email, savedAttachments),
|
|
134
|
+
sender: { id: sender, name: senderDisplayName(sender) || undefined },
|
|
135
|
+
endpointId: this.#options.config.id,
|
|
136
|
+
metadata: Object.freeze({
|
|
137
|
+
subject: email.subject,
|
|
138
|
+
to: email.to,
|
|
139
|
+
cc: email.cc,
|
|
140
|
+
uid: email.uid,
|
|
141
|
+
date: email.date.toISOString(),
|
|
142
|
+
...(savedAttachments.length ? { attachments: savedAttachments } : {}),
|
|
143
|
+
}),
|
|
103
144
|
});
|
|
104
|
-
|
|
105
|
-
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* attachments.enabled 时把入站附件落盘(恢复旧 downloadAttachment 行为,
|
|
148
|
+
* 附加 maxFileSize / allowedTypes 过滤);返回落盘结果供 admit segments/metadata 使用。
|
|
149
|
+
*/
|
|
150
|
+
async #downloadAttachments(email) {
|
|
151
|
+
const config = this.#options.config.attachments;
|
|
152
|
+
if (!config?.enabled || email.attachments.length === 0)
|
|
153
|
+
return [];
|
|
154
|
+
await mkdir(config.downloadPath, { recursive: true });
|
|
155
|
+
const downloadRoot = path.resolve(config.downloadPath);
|
|
156
|
+
const saved = [];
|
|
157
|
+
for (const attachment of email.attachments) {
|
|
158
|
+
// 防路径穿越:发件人可构造 ../../ 等文件名,basename + resolve 后必须落在 downloadPath 内
|
|
159
|
+
const rawName = attachment.filename || `attachment_${Date.now()}`;
|
|
160
|
+
const filename = path.basename(rawName) || `attachment_${Date.now()}`;
|
|
161
|
+
const filepath = path.resolve(downloadRoot, filename);
|
|
162
|
+
if (filepath !== downloadRoot && !filepath.startsWith(downloadRoot + path.sep)) {
|
|
163
|
+
this.#logger.warn(formatCompact({ op: 'email_attachment_skipped', filename: rawName, reason: 'path' }));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (config.allowedTypes?.length && !config.allowedTypes.includes(attachment.contentType ?? '')) {
|
|
167
|
+
this.#logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'type' }));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (attachment.size != null && attachment.size > config.maxFileSize) {
|
|
171
|
+
this.#logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'size' }));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
await writeFile(filepath, attachment.content);
|
|
176
|
+
saved.push({ filename, path: filepath, contentType: attachment.contentType, size: attachment.size });
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
this.#logger.warn(formatCompact({
|
|
180
|
+
op: 'email_attachment_download_failed',
|
|
181
|
+
filename,
|
|
182
|
+
error: error instanceof Error ? error.message : String(error),
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return saved;
|
|
187
|
+
}
|
|
188
|
+
#setupImapListeners(imap) {
|
|
189
|
+
imap.on('mail', () => {
|
|
190
|
+
void this.#emitPlatformEvent('imap.mail', Object.freeze({}));
|
|
191
|
+
void this.#checkForNewEmails();
|
|
192
|
+
});
|
|
193
|
+
imap.on('error', (error) => {
|
|
194
|
+
void this.#emitPlatformEvent('imap.error', error);
|
|
195
|
+
this.#logger.error('IMAP error:', error);
|
|
196
|
+
// imap 通常在 error 后紧跟 end;两处都调度,靠已有定时器去重
|
|
197
|
+
this.#scheduleImapReconnect(imap);
|
|
198
|
+
});
|
|
199
|
+
imap.on('end', () => {
|
|
200
|
+
void this.#emitPlatformEvent('imap.end', Object.freeze({}));
|
|
201
|
+
this.#logger.debug(formatCompact({
|
|
202
|
+
op: 'disconnect',
|
|
203
|
+
mode: 'imap',
|
|
204
|
+
}));
|
|
205
|
+
this.#scheduleImapReconnect(imap);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async #emitPlatformEvent(name, event) {
|
|
209
|
+
await this.emitPlatform(name, event).catch((error) => {
|
|
210
|
+
this.#logger.warn(formatCompact({
|
|
211
|
+
op: 'email_platform_event_failed',
|
|
212
|
+
event: name,
|
|
213
|
+
error: error instanceof Error ? error.message : String(error),
|
|
214
|
+
}));
|
|
106
215
|
});
|
|
107
216
|
}
|
|
108
|
-
|
|
109
|
-
|
|
217
|
+
/** IMAP 断线后按指数退避重建连接并恢复监听(基数 reconnectInterval,封顶 5 分钟)。 */
|
|
218
|
+
#scheduleImapReconnect(source) {
|
|
219
|
+
// A replaced transport may emit a late `end` after its earlier `error`
|
|
220
|
+
// already caused a successful reconnect. Only the currently owned IMAP
|
|
221
|
+
// connection may arm the next generation's reconnect timer.
|
|
222
|
+
if (!this.#started || this.#reconnectTimer || (source && this.#imap !== source))
|
|
110
223
|
return;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
224
|
+
const base = this.#options.config.imap.reconnectInterval;
|
|
225
|
+
const delay = Math.min(base * 2 ** this.#reconnectAttempts, 300_000);
|
|
226
|
+
this.#reconnectAttempts += 1;
|
|
227
|
+
this.#logger.warn(formatCompact({
|
|
228
|
+
op: 'imap_reconnect_scheduled',
|
|
229
|
+
endpoint: this.#options.config.id,
|
|
230
|
+
reconnect_ms: delay,
|
|
231
|
+
}));
|
|
232
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
233
|
+
this.#reconnectTimer = null;
|
|
234
|
+
void this.#reconnectImap();
|
|
235
|
+
}, delay);
|
|
116
236
|
}
|
|
117
|
-
async
|
|
118
|
-
if (!this
|
|
237
|
+
async #reconnectImap() {
|
|
238
|
+
if (!this.#started)
|
|
119
239
|
return;
|
|
240
|
+
let imap;
|
|
120
241
|
try {
|
|
242
|
+
const nextImap = this.#options.createImap?.(this.#options.config.imap)
|
|
243
|
+
?? defaultCreateImap(this.#options.config.imap);
|
|
244
|
+
imap = nextImap;
|
|
245
|
+
this.#imap = nextImap;
|
|
246
|
+
this.#setupImapListeners(nextImap);
|
|
121
247
|
await new Promise((resolve, reject) => {
|
|
122
|
-
|
|
248
|
+
nextImap.once('ready', () => {
|
|
249
|
+
void this.#emitPlatformEvent('imap.ready', Object.freeze({ reconnect: true }));
|
|
250
|
+
resolve();
|
|
251
|
+
});
|
|
252
|
+
nextImap.once('error', (error) => reject(error));
|
|
253
|
+
nextImap.connect();
|
|
254
|
+
});
|
|
255
|
+
this.#reconnectAttempts = 0;
|
|
256
|
+
this.#logger.info(formatCompact({
|
|
257
|
+
op: 'imap_reconnect',
|
|
258
|
+
endpoint: this.#options.config.id,
|
|
259
|
+
ok: true,
|
|
260
|
+
}));
|
|
261
|
+
void this.#checkForNewEmails();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
this.#logger.warn(formatCompact({
|
|
265
|
+
op: 'imap_reconnect',
|
|
266
|
+
endpoint: this.#options.config.id,
|
|
267
|
+
ok: false,
|
|
268
|
+
error: error instanceof Error ? error.message : String(error),
|
|
269
|
+
}));
|
|
270
|
+
this.#scheduleImapReconnect(imap);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
#startEmailCheck() {
|
|
274
|
+
if (this.#checkTimer)
|
|
275
|
+
return;
|
|
276
|
+
this.#checkTimer = setInterval(() => {
|
|
277
|
+
void this.#checkForNewEmails();
|
|
278
|
+
}, this.#options.config.imap.checkInterval);
|
|
279
|
+
void this.#checkForNewEmails();
|
|
280
|
+
}
|
|
281
|
+
async #checkForNewEmails() {
|
|
282
|
+
if (!this.#imap || !this.#started || this.#checking)
|
|
283
|
+
return;
|
|
284
|
+
// 在飞锁:定时器与 mail 事件可能并发触发,串行化避免重复 admit
|
|
285
|
+
this.#checking = true;
|
|
286
|
+
try {
|
|
287
|
+
await new Promise((resolve, reject) => {
|
|
288
|
+
this.#imap.openBox(this.#options.config.imap.mailbox, false, (error) => {
|
|
123
289
|
if (error)
|
|
124
290
|
return reject(error);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (results.length === 0) {
|
|
291
|
+
this.#imap.search(['UNSEEN'], (searchError, results) => {
|
|
292
|
+
if (searchError)
|
|
293
|
+
return reject(searchError);
|
|
294
|
+
if (!results.length)
|
|
130
295
|
return resolve();
|
|
131
|
-
|
|
132
|
-
// 获取邮件
|
|
133
|
-
const fetch = this.imapConnection.fetch(results, {
|
|
296
|
+
const fetch = this.#imap.fetch(results, {
|
|
134
297
|
bodies: '',
|
|
135
|
-
markSeen: this
|
|
298
|
+
markSeen: this.#options.config.imap.markSeen,
|
|
136
299
|
});
|
|
137
300
|
fetch.on('message', (msg, seqno) => {
|
|
138
|
-
this
|
|
301
|
+
this.#handleImapMessage(msg, seqno);
|
|
139
302
|
});
|
|
140
|
-
fetch.once('error', reject);
|
|
141
|
-
fetch.once('end', resolve);
|
|
303
|
+
fetch.once('error', (fetchError) => reject(fetchError));
|
|
304
|
+
fetch.once('end', () => resolve());
|
|
142
305
|
});
|
|
143
306
|
});
|
|
144
307
|
});
|
|
145
308
|
}
|
|
146
309
|
catch (error) {
|
|
147
|
-
this
|
|
310
|
+
this.#logger.error('Error checking for new emails:', error);
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
this.#checking = false;
|
|
148
314
|
}
|
|
149
315
|
}
|
|
150
|
-
handleImapMessage(msg,
|
|
316
|
+
#handleImapMessage(msg, _seqno) {
|
|
151
317
|
let body = '';
|
|
152
318
|
let uid = 0;
|
|
153
319
|
msg.on('body', (stream) => {
|
|
154
320
|
stream.on('data', (chunk) => {
|
|
155
|
-
body += chunk.toString('utf8');
|
|
321
|
+
body += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
156
322
|
});
|
|
157
323
|
});
|
|
158
324
|
msg.once('attributes', (attrs) => {
|
|
159
|
-
uid = attrs.uid;
|
|
160
|
-
});
|
|
161
|
-
msg.once('end', async () => {
|
|
162
|
-
try {
|
|
163
|
-
const parsed = await simpleParser(body);
|
|
164
|
-
const emailMessage = this.parseEmailMessage(parsed, uid);
|
|
165
|
-
const formattedMessage = this.$formatMessage(emailMessage);
|
|
166
|
-
this.adapter.emit('message.receive', formattedMessage);
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
this.logger.error('Error parsing email:', error);
|
|
170
|
-
}
|
|
325
|
+
uid = attrs.uid ?? 0;
|
|
171
326
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return addr.map((a) => a.text || a.address || a.toString());
|
|
179
|
-
}
|
|
180
|
-
return [addr.text || addr.address || addr.toString()];
|
|
181
|
-
};
|
|
182
|
-
return {
|
|
183
|
-
messageId: parsed.messageId || '',
|
|
184
|
-
from: parsed.from ? getAddressText(parsed.from)[0] || '' : '',
|
|
185
|
-
to: getAddressText(parsed.to),
|
|
186
|
-
cc: getAddressText(parsed.cc),
|
|
187
|
-
bcc: getAddressText(parsed.bcc),
|
|
188
|
-
subject: parsed.subject || '',
|
|
189
|
-
text: parsed.text || '',
|
|
190
|
-
html: parsed.html ? parsed.html.toString() : '',
|
|
191
|
-
attachments: parsed.attachments || [],
|
|
192
|
-
date: parsed.date || new Date(),
|
|
193
|
-
uid
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
$formatMessage(emailMsg) {
|
|
197
|
-
// 确定频道类型和ID
|
|
198
|
-
const channelType = 'private';
|
|
199
|
-
const channelId = emailMsg.from;
|
|
200
|
-
// 解析邮件内容
|
|
201
|
-
const content = EmailEndpoint.parseEmailContent(emailMsg);
|
|
202
|
-
const result = Message.from(emailMsg, {
|
|
203
|
-
$id: emailMsg.messageId,
|
|
204
|
-
$adapter: 'email',
|
|
205
|
-
$endpoint: this.$config.name,
|
|
206
|
-
$sender: {
|
|
207
|
-
id: emailMsg.from,
|
|
208
|
-
name: emailMsg.from.split('<')[0].trim() || emailMsg.from
|
|
209
|
-
},
|
|
210
|
-
$channel: {
|
|
211
|
-
id: channelId,
|
|
212
|
-
type: channelType
|
|
213
|
-
},
|
|
214
|
-
$raw: JSON.stringify(emailMsg),
|
|
215
|
-
$timestamp: emailMsg.date.getTime(),
|
|
216
|
-
$content: content,
|
|
217
|
-
$recall: async () => {
|
|
218
|
-
// 邮件适配器暂时不支持撤回消息
|
|
219
|
-
},
|
|
220
|
-
$reply: async (content) => {
|
|
221
|
-
return await this.adapter.sendMessage({
|
|
222
|
-
context: this.$config.context,
|
|
223
|
-
endpoint: this.$config.name,
|
|
224
|
-
id: emailMsg.from,
|
|
225
|
-
type: 'private',
|
|
226
|
-
content
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
});
|
|
230
|
-
return result;
|
|
231
|
-
}
|
|
232
|
-
static parseEmailContent(email) {
|
|
233
|
-
const segments = [];
|
|
234
|
-
// 添加主题(如果有且不为空)
|
|
235
|
-
if (email.subject) {
|
|
236
|
-
segments.push(segment.text(`Subject: ${email.subject}\n\n`));
|
|
237
|
-
}
|
|
238
|
-
// 添加文本内容
|
|
239
|
-
if (email.text) {
|
|
240
|
-
segments.push(segment.text(email.text));
|
|
241
|
-
}
|
|
242
|
-
// 如果没有纯文本但有HTML,尝试转换
|
|
243
|
-
if (!email.text && email.html) {
|
|
244
|
-
const textFromHtml = EmailEndpoint.htmlToText(email.html);
|
|
245
|
-
if (textFromHtml) {
|
|
246
|
-
segments.push(segment.text(textFromHtml));
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
// 处理附件
|
|
250
|
-
for (const attachment of email.attachments) {
|
|
251
|
-
if (attachment.contentType?.startsWith('image/')) {
|
|
252
|
-
segments.push(segment('image', {
|
|
253
|
-
filename: attachment.filename,
|
|
254
|
-
contentType: attachment.contentType,
|
|
255
|
-
size: attachment.size
|
|
256
|
-
}));
|
|
257
|
-
}
|
|
258
|
-
else {
|
|
259
|
-
segments.push(segment('file', {
|
|
260
|
-
filename: attachment.filename,
|
|
261
|
-
contentType: attachment.contentType,
|
|
262
|
-
size: attachment.size
|
|
263
|
-
}));
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return segments.length > 0 ? segments : [segment.text('(Empty email)')];
|
|
267
|
-
}
|
|
268
|
-
async $sendMessage(options) {
|
|
269
|
-
if (!this.smtpTransporter) {
|
|
270
|
-
throw new Error('SMTP transporter not initialized');
|
|
271
|
-
}
|
|
272
|
-
try {
|
|
273
|
-
const mailOptions = await this.formatSendContent(options);
|
|
274
|
-
const info = await this.smtpTransporter.sendMail(mailOptions);
|
|
275
|
-
this.logger.debug('Email sent:', info.messageId);
|
|
276
|
-
return info.messageId || '';
|
|
277
|
-
}
|
|
278
|
-
catch (error) {
|
|
279
|
-
this.logger.error('Failed to send email:', error);
|
|
280
|
-
throw error;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
async $recallMessage(id) {
|
|
284
|
-
// 邮件适配器暂时不支持撤回消息
|
|
285
|
-
}
|
|
286
|
-
async formatSendContent(options) {
|
|
287
|
-
const mailOptions = {
|
|
288
|
-
from: this.$config.smtp.auth.user,
|
|
289
|
-
to: options.id,
|
|
290
|
-
subject: 'Message from Bot'
|
|
291
|
-
};
|
|
292
|
-
if (typeof options.content === 'string') {
|
|
293
|
-
mailOptions.text = options.content;
|
|
294
|
-
}
|
|
295
|
-
else if (Array.isArray(options.content)) {
|
|
296
|
-
const textParts = [];
|
|
297
|
-
const htmlParts = [];
|
|
298
|
-
const attachments = [];
|
|
299
|
-
for (const item of options.content) {
|
|
300
|
-
if (typeof item === 'string') {
|
|
301
|
-
textParts.push(item);
|
|
302
|
-
htmlParts.push(item.replace(/\n/g, '<br>'));
|
|
303
|
-
}
|
|
304
|
-
else {
|
|
305
|
-
const segment = item;
|
|
306
|
-
switch (segment.type) {
|
|
307
|
-
case 'text':
|
|
308
|
-
const textContent = segment.data.text || segment.data.content || '';
|
|
309
|
-
textParts.push(textContent);
|
|
310
|
-
htmlParts.push(textContent.replace(/\n/g, '<br>'));
|
|
311
|
-
break;
|
|
312
|
-
case 'image':
|
|
313
|
-
if (segment.data.url) {
|
|
314
|
-
attachments.push({
|
|
315
|
-
filename: segment.data.filename || 'image.png',
|
|
316
|
-
path: segment.data.url
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
break;
|
|
320
|
-
case 'file':
|
|
321
|
-
if (segment.data.url) {
|
|
322
|
-
attachments.push({
|
|
323
|
-
filename: segment.data.filename || 'file',
|
|
324
|
-
path: segment.data.url
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
break;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
if (textParts.length > 0) {
|
|
332
|
-
mailOptions.text = textParts.join('\n');
|
|
333
|
-
mailOptions.html = htmlParts.join('<br>');
|
|
334
|
-
}
|
|
335
|
-
if (attachments.length > 0) {
|
|
336
|
-
mailOptions.attachments = attachments;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
// 如果有回复对象,可以在这里处理
|
|
340
|
-
// 邮件适配器暂时不支持回复对象
|
|
341
|
-
return mailOptions;
|
|
342
|
-
}
|
|
343
|
-
// 下载附件到本地
|
|
344
|
-
async downloadAttachment(attachment) {
|
|
345
|
-
if (!this.$config.attachments?.enabled || !this.$config.attachments.downloadPath) {
|
|
346
|
-
throw new Error('Attachment download is not enabled');
|
|
347
|
-
}
|
|
348
|
-
const downloadPath = this.$config.attachments.downloadPath;
|
|
349
|
-
await fs.mkdir(downloadPath, { recursive: true });
|
|
350
|
-
const filename = attachment.filename || `attachment_${Date.now()}`;
|
|
351
|
-
const filepath = path.join(downloadPath, filename);
|
|
352
|
-
return new Promise((resolve, reject) => {
|
|
353
|
-
const writeStream = createWriteStream(filepath);
|
|
354
|
-
writeStream.write(attachment.content);
|
|
355
|
-
writeStream.end();
|
|
356
|
-
writeStream.on('finish', () => resolve(filepath));
|
|
357
|
-
writeStream.on('error', reject);
|
|
327
|
+
msg.once('end', () => {
|
|
328
|
+
void simpleParser(body).then((parsed) => {
|
|
329
|
+
this.admit(parseEmailMessage(parsed, uid));
|
|
330
|
+
}).catch((error) => {
|
|
331
|
+
this.#logger.error('Error parsing email:', error);
|
|
332
|
+
});
|
|
358
333
|
});
|
|
359
334
|
}
|
|
360
|
-
/**
|
|
361
|
-
* HTML → 纯文本转换,处理常见标签和实体
|
|
362
|
-
*/
|
|
363
|
-
static htmlToText(html) {
|
|
364
|
-
return htmlToPlainTextWithBlockBreaks(html);
|
|
365
|
-
}
|
|
366
335
|
}
|
|
367
|
-
// 创建和注册适配器
|
|
368
|
-
//# sourceMappingURL=endpoint.js.map
|
package/lib/index.d.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
}
|
|
7
|
-
export * from "./types.js";
|
|
8
|
-
export { EmailEndpoint } from "./endpoint.js";
|
|
9
|
-
export { EmailAdapter } from "./adapter.js";
|
|
10
|
-
//# sourceMappingURL=index.d.ts.map
|
|
1
|
+
export { addressListText, emailInboundConversation, formatInboundContent, formatInboundSegments, formatOutboundMail, htmlToText, parseEmailMessage, resolveEmailConfig, senderDisplayName, type EmailAdapterConfig, type EmailAttachmentsConfig, type EmailMessage, type EmailWireSegment, type ImapConfig, type ResolvedEmailConfig, type SavedEmailAttachment, type SmtpConfig, } from './protocol.js';
|
|
2
|
+
export { EmailClient, emailClient, type EmailClientEventMap, } from './client.js';
|
|
3
|
+
export { EmailEndpoint, type EmailEndpointOptions, } from './endpoint.js';
|
|
4
|
+
export { defaultCreateImap, defaultCreateSmtp, type EmailImapFetchMessage, type EmailImapTransport, type EmailSmtpTransport, } from './transport.js';
|