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