@zhin.js/adapter-email 1.0.0 → 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.
@@ -0,0 +1,364 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ /**
3
+ * EmailEndpoint — lifecycle, SMTP outbound, IMAP inbound polling.
4
+ */
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
+ }
37
+
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);
61
+ }
62
+
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();
78
+ });
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;
89
+ }
90
+ }
91
+
92
+ open(): void {
93
+ this.#open = true;
94
+ }
95
+
96
+ close(): void {
97
+ this.#open = false;
98
+ }
99
+
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;
107
+ }
108
+ if (this.#reconnectTimer) {
109
+ clearTimeout(this.#reconnectTimer);
110
+ this.#reconnectTimer = null;
111
+ }
112
+ if (this.#imap) {
113
+ try {
114
+ this.#imap.end();
115
+ } catch {
116
+ /* ignore */
117
+ }
118
+ this.#imap = null;
119
+ }
120
+ if (this.#smtp) {
121
+ try {
122
+ this.#smtp.close();
123
+ } catch {
124
+ /* ignore */
125
+ }
126
+ this.#smtp = null;
127
+ }
128
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
129
+ }
130
+
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
+ }
141
+
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
+ }
154
+
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
+ }
177
+
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
+ }
217
+ }
218
+ return saved;
219
+ }
220
+
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
+ }
241
+
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
+ }
251
+
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
+ }
271
+
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);
304
+ }
305
+ }
306
+
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
+ }
314
+
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
+ });
336
+ });
337
+ });
338
+ } catch (error) {
339
+ this.#logger.error('Error checking for new emails:', error);
340
+ } finally {
341
+ this.#checking = false;
342
+ }
343
+ }
344
+
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
+ }
364
+ }
package/src/index.ts CHANGED
@@ -1,31 +1,38 @@
1
- /**
2
- * Email 适配器入口:类型扩展、导出、注册
3
- */
4
- import { usePlugin, type Plugin, type Context } from "zhin.js";
5
- import { EmailAdapter } from "./adapter.js";
1
+ export {
2
+ addressListText,
3
+ emailInboundConversation,
4
+ formatInboundContent,
5
+ formatInboundSegments,
6
+ formatOutboundMail,
7
+ htmlToText,
8
+ parseEmailMessage,
9
+ resolveEmailConfig,
10
+ senderDisplayName,
11
+ type EmailAdapterConfig,
12
+ type EmailAttachmentsConfig,
13
+ type EmailMessage,
14
+ type EmailWireSegment,
15
+ type ImapConfig,
16
+ type ResolvedEmailConfig,
17
+ type SavedEmailAttachment,
18
+ type SmtpConfig,
19
+ } from './protocol.js';
6
20
 
7
- declare module "zhin.js" {
8
- interface Adapters {
9
- email: EmailAdapter;
10
- }
11
- }
21
+ export {
22
+ EmailClient,
23
+ emailClient,
24
+ type EmailClientEventMap,
25
+ } from './client.js';
12
26
 
13
- export * from "./types.js";
14
- export { EmailBot } from "./bot.js";
15
- export { EmailAdapter } from "./adapter.js";
27
+ export {
28
+ EmailEndpoint,
29
+ type EmailEndpointOptions,
30
+ } from './endpoint.js';
16
31
 
17
- const plugin = usePlugin();
18
- const { provide } = plugin;
19
-
20
- provide({
21
- name: "email",
22
- description: "Email Bot Adapter",
23
- mounted: async (p: Plugin) => {
24
- const adapter = new EmailAdapter(p);
25
- await adapter.start();
26
- return adapter;
27
- },
28
- dispose: async (adapter: EmailAdapter) => {
29
- await adapter.stop();
30
- },
31
- } as Context<"email">);
32
+ export {
33
+ defaultCreateImap,
34
+ defaultCreateSmtp,
35
+ type EmailImapFetchMessage,
36
+ type EmailImapTransport,
37
+ type EmailSmtpTransport,
38
+ } from './transport.js';