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