@zhin.js/adapter-email 3.0.3 → 4.0.1

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 CHANGED
@@ -1,5 +1,53 @@
1
1
  # @zhin.js/adapter-email
2
2
 
3
+ ## 4.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
8
+
9
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
10
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
11
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
12
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
13
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
14
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
15
+
16
+ - Updated dependencies [cdf64e7]
17
+ - Updated dependencies [5691aba]
18
+ - Updated dependencies [078e3f7]
19
+ - Updated dependencies [9c997b2]
20
+ - Updated dependencies [09d4f25]
21
+ - Updated dependencies [43485a9]
22
+ - Updated dependencies [f0ec5ab]
23
+ - Updated dependencies [3e925d0]
24
+ - Updated dependencies [fa66c4c]
25
+ - Updated dependencies [fa66c4c]
26
+ - Updated dependencies [6cb6152]
27
+ - @zhin.js/plugin-runtime@1.1.1
28
+ - zhin.js@5.0.1
29
+ - @zhin.js/adapter@1.1.1
30
+ - @zhin.js/core@1.4.1
31
+
32
+ ## 4.0.0
33
+
34
+ ### Patch Changes
35
+
36
+ - 7db69c1: 命令前缀改为适配器配置项:`MessageDispatcher` 不再硬编码 `/`,默认按消息所属适配器实例 config 的 `commandPrefix` 解析(默认 `''` 无前缀,任意文本按命令匹配),`endpoints[i].commandPrefix` 逐项覆盖;`ImRuntime({ commandPrefix })` 仍可设全局静态前缀。全部 20 个平台适配器 schema 新增 `commandPrefix` 属性。
37
+
38
+ BREAKING(行为变化):未配置时命令不再需要 `/` 前缀——原 `/zt` 写法不再命中,直接发 `zt` 即可;需要斜杠风格的适配器请在配置里显式设 `commandPrefix: '/'`。
39
+
40
+ - 713445c: 适配器配置格式定稿(不兼容旧格式):`plugins.<adapter>` 顶层仅共享字段 + `commandPrefix`,`endpoints[i]` 携带 endpoint 级字段(`name` + 凭据,各 schema 已类型化),`endpoints` 为必填(icqq 另需顶层 `master`);icqq 新增 `trusted` 列表(顶层/逐项均可)。scaffold-wizard 全部字段式与自定义 configure() 产出改为新格式,examples(full-bot / qq-games-bot)与 20 个适配器 README 同步迁移。
41
+ - Updated dependencies [7db69c1]
42
+ - Updated dependencies [e5c84ed]
43
+ - Updated dependencies [3ea84a0]
44
+ - Updated dependencies [1ddcd70]
45
+ - Updated dependencies [ac9da66]
46
+ - @zhin.js/core@1.4.0
47
+ - @zhin.js/adapter@1.1.0
48
+ - @zhin.js/plugin-runtime@1.1.0
49
+ - zhin.js@5.0.0
50
+
3
51
  ## 3.0.3
4
52
 
5
53
  ### Patch Changes
package/README.md CHANGED
@@ -41,20 +41,21 @@ pnpm add @zhin.js/adapter-email
41
41
  # zhin.config.yml(Plugin Runtime)
42
42
  plugins:
43
43
  email:
44
- name: my-email-bot
45
- smtp:
46
- host: smtp.example.com
47
- port: 465
48
- secure: true
49
- auth:
50
- user: bot@example.com
51
- pass: "${EMAIL_PASSWORD}"
52
- imap:
53
- host: imap.example.com
54
- port: 993
55
- tls: true
56
- user: bot@example.com
57
- password: "${EMAIL_PASSWORD}"
44
+ endpoints:
45
+ - name: my-email-bot
46
+ smtp:
47
+ host: smtp.example.com
48
+ port: 465
49
+ secure: true
50
+ auth:
51
+ user: bot@example.com
52
+ pass: "${EMAIL_PASSWORD}"
53
+ imap:
54
+ host: imap.example.com
55
+ port: 993
56
+ tls: true
57
+ user: bot@example.com
58
+ password: "${EMAIL_PASSWORD}"
58
59
  ```
59
60
 
60
61
  根插件 `zhin.plugins`(或项目图)需引用 `@zhin.js/adapter-email`(`instanceKey: email`)。
package/adapters/email.ts CHANGED
@@ -19,6 +19,12 @@ export type {
19
19
 
20
20
  export default defineAdapter<EmailAdapterConfig>({
21
21
  capabilities: ['inbound', 'outbound'],
22
+ // image/file 段映射为邮件附件:canonical MediaRef kind=url/path 均可作
23
+ // nodemailer attachment.path(URL 由 nodemailer 拉流);邮件无交互面,交互段降级纯文本。
24
+ segments: {
25
+ outboundMedia: ['url', 'path'],
26
+ interactive: 'text',
27
+ },
22
28
  create(context) {
23
29
  return new EmailEndpoint({
24
30
  id: context.id,
package/lib/endpoint.d.ts CHANGED
@@ -10,6 +10,10 @@ export interface EmailEndpointOptions {
10
10
  readonly createSmtp?: (config: ResolvedEmailConfig['smtp']) => EmailSmtpTransport | Promise<EmailSmtpTransport>;
11
11
  readonly createImap?: (config: ResolvedEmailConfig['imap']) => EmailImapTransport;
12
12
  }
13
+ /**
14
+ * Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
15
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
16
+ */
13
17
  export declare class EmailEndpoint implements EndpointInstance {
14
18
  #private;
15
19
  constructor(options: EmailEndpointOptions);
package/lib/endpoint.js CHANGED
@@ -5,14 +5,21 @@ import { mkdir, writeFile } from 'node:fs/promises';
5
5
  import * as path from 'node:path';
6
6
  import { simpleParser } from 'mailparser';
7
7
  import { formatCompact, getLogger } from '@zhin.js/logger';
8
- import { formatInboundContent, formatOutboundMail, parseEmailMessage, senderDisplayName, } from './protocol.js';
8
+ import { formatInboundContent, formatInboundSegments, formatOutboundMail, parseEmailMessage, senderDisplayName, } from './protocol.js';
9
9
  import { defaultCreateImap, defaultCreateSmtp, } from './transport.js';
10
10
  const logger = getLogger('email');
11
+ /**
12
+ * Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
13
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
14
+ */
11
15
  export class EmailEndpoint {
12
16
  #options;
13
17
  #smtp = null;
14
18
  #imap = null;
15
19
  #checkTimer = null;
20
+ #reconnectTimer = null;
21
+ #reconnectAttempts = 0;
22
+ #checking = false;
16
23
  #open = false;
17
24
  #started = false;
18
25
  constructor(options) {
@@ -35,6 +42,7 @@ export class EmailEndpoint {
35
42
  this.#imap.connect();
36
43
  });
37
44
  logger.debug(formatCompact({ endpoint: name, mode: 'imap' }));
45
+ this.#reconnectAttempts = 0;
38
46
  this.#startEmailCheck();
39
47
  }
40
48
  catch (error) {
@@ -51,10 +59,16 @@ export class EmailEndpoint {
51
59
  }
52
60
  async stop() {
53
61
  this.#open = false;
62
+ // 先复位 #started,避免 imap.end() 触发的 'end' 事件又武装重连定时器
63
+ this.#started = false;
54
64
  if (this.#checkTimer) {
55
65
  clearInterval(this.#checkTimer);
56
66
  this.#checkTimer = null;
57
67
  }
68
+ if (this.#reconnectTimer) {
69
+ clearTimeout(this.#reconnectTimer);
70
+ this.#reconnectTimer = null;
71
+ }
58
72
  if (this.#imap) {
59
73
  try {
60
74
  this.#imap.end();
@@ -73,7 +87,6 @@ export class EmailEndpoint {
73
87
  }
74
88
  this.#smtp = null;
75
89
  }
76
- this.#started = false;
77
90
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
78
91
  }
79
92
  async send({ target, payload }) {
@@ -107,6 +120,7 @@ export class EmailEndpoint {
107
120
  adapter: this.#options.id,
108
121
  target: sender,
109
122
  content,
123
+ segments: formatInboundSegments(email, savedAttachments),
110
124
  sender: senderDisplayName(sender),
111
125
  id: email.messageId || undefined,
112
126
  metadata: Object.freeze({
@@ -122,16 +136,24 @@ export class EmailEndpoint {
122
136
  }
123
137
  /**
124
138
  * attachments.enabled 时把入站附件落盘(恢复旧 downloadAttachment 行为,
125
- * 附加 maxFileSize / allowedTypes 过滤);返回落盘结果供 admit metadata 使用。
139
+ * 附加 maxFileSize / allowedTypes 过滤);返回落盘结果供 admit segments/metadata 使用。
126
140
  */
127
141
  async #downloadAttachments(email) {
128
142
  const config = this.#options.config.attachments;
129
143
  if (!config?.enabled || email.attachments.length === 0)
130
144
  return [];
131
145
  await mkdir(config.downloadPath, { recursive: true });
146
+ const downloadRoot = path.resolve(config.downloadPath);
132
147
  const saved = [];
133
148
  for (const attachment of email.attachments) {
134
- const filename = attachment.filename || `attachment_${Date.now()}`;
149
+ // 防路径穿越:发件人可构造 ../../ 等文件名,basename + resolve 后必须落在 downloadPath 内
150
+ const rawName = attachment.filename || `attachment_${Date.now()}`;
151
+ const filename = path.basename(rawName) || `attachment_${Date.now()}`;
152
+ const filepath = path.resolve(downloadRoot, filename);
153
+ if (filepath !== downloadRoot && !filepath.startsWith(downloadRoot + path.sep)) {
154
+ logger.warn(formatCompact({ op: 'email_attachment_skipped', filename: rawName, reason: 'path' }));
155
+ continue;
156
+ }
135
157
  if (config.allowedTypes?.length && !config.allowedTypes.includes(attachment.contentType ?? '')) {
136
158
  logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'type' }));
137
159
  continue;
@@ -140,7 +162,6 @@ export class EmailEndpoint {
140
162
  logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'size' }));
141
163
  continue;
142
164
  }
143
- const filepath = path.join(config.downloadPath, filename);
144
165
  try {
145
166
  await writeFile(filepath, attachment.content);
146
167
  saved.push({ filename, path: filepath, contentType: attachment.contentType, size: attachment.size });
@@ -161,6 +182,8 @@ export class EmailEndpoint {
161
182
  });
162
183
  imap.on('error', (error) => {
163
184
  logger.error('IMAP error:', error);
185
+ // imap 通常在 error 后紧跟 end;两处都调度,靠已有定时器去重
186
+ this.#scheduleImapReconnect();
164
187
  });
165
188
  imap.on('end', () => {
166
189
  logger.debug(formatCompact({
@@ -168,8 +191,57 @@ export class EmailEndpoint {
168
191
  endpoint: this.#options.config.name,
169
192
  mode: 'imap',
170
193
  }));
194
+ this.#scheduleImapReconnect();
171
195
  });
172
196
  }
197
+ /** IMAP 断线后按指数退避重建连接并恢复监听(基数 reconnectInterval,封顶 5 分钟)。 */
198
+ #scheduleImapReconnect() {
199
+ if (!this.#started || this.#reconnectTimer)
200
+ return;
201
+ const base = this.#options.config.imap.reconnectInterval;
202
+ const delay = Math.min(base * 2 ** this.#reconnectAttempts, 300_000);
203
+ this.#reconnectAttempts += 1;
204
+ logger.warn(formatCompact({
205
+ op: 'imap_reconnect_scheduled',
206
+ endpoint: this.#options.config.name,
207
+ reconnect_ms: delay,
208
+ }));
209
+ this.#reconnectTimer = setTimeout(() => {
210
+ this.#reconnectTimer = null;
211
+ void this.#reconnectImap();
212
+ }, delay);
213
+ }
214
+ async #reconnectImap() {
215
+ if (!this.#started)
216
+ return;
217
+ try {
218
+ const imap = this.#options.createImap?.(this.#options.config.imap)
219
+ ?? defaultCreateImap(this.#options.config.imap);
220
+ this.#imap = imap;
221
+ this.#setupImapListeners(imap);
222
+ await new Promise((resolve, reject) => {
223
+ imap.once('ready', () => resolve());
224
+ imap.once('error', (error) => reject(error));
225
+ imap.connect();
226
+ });
227
+ this.#reconnectAttempts = 0;
228
+ logger.info(formatCompact({
229
+ op: 'imap_reconnect',
230
+ endpoint: this.#options.config.name,
231
+ ok: true,
232
+ }));
233
+ void this.#checkForNewEmails();
234
+ }
235
+ catch (error) {
236
+ logger.warn(formatCompact({
237
+ op: 'imap_reconnect',
238
+ endpoint: this.#options.config.name,
239
+ ok: false,
240
+ error: error instanceof Error ? error.message : String(error),
241
+ }));
242
+ this.#scheduleImapReconnect();
243
+ }
244
+ }
173
245
  #startEmailCheck() {
174
246
  if (this.#checkTimer)
175
247
  return;
@@ -179,8 +251,10 @@ export class EmailEndpoint {
179
251
  void this.#checkForNewEmails();
180
252
  }
181
253
  async #checkForNewEmails() {
182
- if (!this.#imap || !this.#started)
254
+ if (!this.#imap || !this.#started || this.#checking)
183
255
  return;
256
+ // 在飞锁:定时器与 mail 事件可能并发触发,串行化避免重复 admit
257
+ this.#checking = true;
184
258
  try {
185
259
  await new Promise((resolve, reject) => {
186
260
  this.#imap.openBox(this.#options.config.imap.mailbox, false, (error) => {
@@ -207,6 +281,9 @@ export class EmailEndpoint {
207
281
  catch (error) {
208
282
  logger.error('Error checking for new emails:', error);
209
283
  }
284
+ finally {
285
+ this.#checking = false;
286
+ }
210
287
  }
211
288
  #handleImapMessage(msg, _seqno) {
212
289
  let body = '';
package/lib/index.d.ts CHANGED
@@ -1,3 +1,3 @@
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';
1
+ export { addressListText, 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
2
  export { EmailEndpoint, type EmailEndpointOptions, } from './endpoint.js';
3
3
  export { defaultCreateImap, defaultCreateSmtp, type EmailImapFetchMessage, type EmailImapTransport, type EmailSmtpTransport, } from './transport.js';
package/lib/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { addressListText, formatInboundContent, formatOutboundMail, htmlToText, parseEmailMessage, resolveEmailConfig, senderDisplayName, } from './protocol.js';
1
+ export { addressListText, formatInboundContent, formatInboundSegments, formatOutboundMail, htmlToText, parseEmailMessage, resolveEmailConfig, senderDisplayName, } from './protocol.js';
2
2
  export { EmailEndpoint, } from './endpoint.js';
3
3
  export { defaultCreateImap, defaultCreateSmtp, } from './transport.js';
package/lib/protocol.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  */
5
5
  import type { Attachment } from 'mailparser';
6
+ import type { Segment } from '@zhin.js/core/runtime';
6
7
  export interface SmtpConfig {
7
8
  readonly host: string;
8
9
  readonly port: number;
@@ -19,6 +20,8 @@ export interface ImapConfig {
19
20
  readonly user: string;
20
21
  readonly password: string;
21
22
  readonly checkInterval?: number;
23
+ /** IMAP 断线重连基础间隔(指数退避基数),毫秒。 */
24
+ readonly reconnectInterval?: number;
22
25
  readonly mailbox?: string;
23
26
  readonly markSeen?: boolean;
24
27
  }
@@ -43,7 +46,7 @@ export interface ResolvedEmailConfig {
43
46
  readonly context: 'email';
44
47
  readonly name: string;
45
48
  readonly smtp: SmtpConfig;
46
- readonly imap: Required<Pick<ImapConfig, 'checkInterval' | 'mailbox' | 'markSeen'>> & ImapConfig;
49
+ readonly imap: Required<Pick<ImapConfig, 'checkInterval' | 'reconnectInterval' | 'mailbox' | 'markSeen'>> & ImapConfig;
47
50
  readonly attachments?: {
48
51
  readonly enabled: boolean;
49
52
  readonly downloadPath: string;
@@ -85,6 +88,19 @@ export declare function parseEmailMessage(parsed: {
85
88
  }, uid: number): EmailMessage;
86
89
  /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
87
90
  export declare function formatInboundContent(email: EmailMessage): string;
91
+ /** 已落盘的入站附件(attachments.enabled 下载结果)。 */
92
+ export interface SavedEmailAttachment {
93
+ readonly filename: string;
94
+ readonly path: string;
95
+ readonly contentType?: string;
96
+ readonly size?: number;
97
+ }
98
+ /**
99
+ * 入站邮件 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
100
+ * 已落盘附件映射为 image/file 段,MediaRef kind=path 指向下载路径;
101
+ * 未下载的附件(disabled / 被过滤)只保留 content 里的占位文本。
102
+ */
103
+ export declare function formatInboundSegments(email: EmailMessage, savedAttachments?: readonly SavedEmailAttachment[]): Segment[];
88
104
  export declare function senderDisplayName(from: string): string;
89
105
  /**
90
106
  * Wire-encode an already-rendered outbound payload into nodemailer options.
package/lib/protocol.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Email SMTP/IMAP helpers (no legacy Adapter/Endpoint / segment-mapper).
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  */
5
- import { htmlToPlainTextWithBlockBreaks } from '@zhin.js/core';
5
+ import { htmlToPlainTextWithBlockBreaks, isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
6
6
  export function resolveEmailConfig(config = {}) {
7
7
  const entry = config.endpoints?.find((item) => item.context === 'email');
8
8
  const smtp = config.smtp ?? entry?.smtp;
@@ -19,7 +19,7 @@ export function resolveEmailConfig(config = {}) {
19
19
  ? {
20
20
  enabled: true,
21
21
  downloadPath: attachmentsSource.downloadPath || './downloads/email',
22
- maxFileSize: attachmentsSource.maxFileSize || 10 * 1024 * 1024,
22
+ maxFileSize: Math.max(attachmentsSource.maxFileSize || 10 * 1024 * 1024, 1),
23
23
  allowedTypes: attachmentsSource.allowedTypes,
24
24
  }
25
25
  : undefined;
@@ -29,7 +29,9 @@ export function resolveEmailConfig(config = {}) {
29
29
  smtp,
30
30
  imap: {
31
31
  ...imap,
32
- checkInterval: imap.checkInterval ?? 60_000,
32
+ // 数值下限:0/负数会导致 setInterval(0) 风暴
33
+ checkInterval: Math.max(imap.checkInterval ?? 60_000, 1_000),
34
+ reconnectInterval: Math.max(imap.reconnectInterval ?? 5_000, 1_000),
33
35
  mailbox: imap.mailbox ?? 'INBOX',
34
36
  markSeen: imap.markSeen !== false,
35
37
  },
@@ -89,6 +91,33 @@ export function formatInboundContent(email) {
89
91
  const text = parts.join('\n').trim();
90
92
  return text || '(Empty email)';
91
93
  }
94
+ /**
95
+ * 入站邮件 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
96
+ * 已落盘附件映射为 image/file 段,MediaRef kind=path 指向下载路径;
97
+ * 未下载的附件(disabled / 被过滤)只保留 content 里的占位文本。
98
+ */
99
+ export function formatInboundSegments(email, savedAttachments = []) {
100
+ const out = [];
101
+ const content = formatInboundContent(email);
102
+ if (content)
103
+ out.push({ type: 'text', data: { text: content } });
104
+ for (const saved of savedAttachments) {
105
+ const type = saved.contentType?.startsWith('image/') ? 'image' : 'file';
106
+ out.push({
107
+ type,
108
+ data: {
109
+ media: {
110
+ kind: 'path',
111
+ value: saved.path,
112
+ ...(saved.contentType ? { mime_type: saved.contentType } : {}),
113
+ },
114
+ name: saved.filename,
115
+ ...(type === 'image' ? { alt: saved.filename } : {}),
116
+ },
117
+ });
118
+ }
119
+ return out;
120
+ }
92
121
  export function senderDisplayName(from) {
93
122
  const name = from.split('<')[0]?.trim();
94
123
  return name || from;
@@ -138,10 +167,13 @@ export function formatOutboundMail(payload, options) {
138
167
  }
139
168
  case 'image':
140
169
  case 'file': {
141
- if (typeof data.url === 'string' && data.url) {
170
+ // canonical MediaRef 优先(kind url/path 均可作 nodemailer attachment.path),
171
+ // 旧 wire 字段 `{url,file,base64}` 经 mediaRefFromLegacyData 兼容。
172
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
173
+ if (media && (media.kind === 'url' || media.kind === 'path')) {
142
174
  attachments.push({
143
- filename: String(data.filename || (item.type === 'image' ? 'image.png' : 'file')),
144
- path: data.url,
175
+ filename: String(data.filename || data.name || (item.type === 'image' ? 'image.png' : 'file')),
176
+ path: media.value,
145
177
  });
146
178
  }
147
179
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-email",
3
- "version": "3.0.3",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "description": "Zhin.js Email adapter for Plugin Runtime (SMTP/IMAP)",
6
6
  "main": "./lib/index.js",
@@ -9,16 +9,16 @@
9
9
  "imap": "^0.8.19",
10
10
  "mailparser": "^3.9.14",
11
11
  "nodemailer": "^9.0.3",
12
- "@zhin.js/adapter": "1.0.1",
13
- "@zhin.js/core": "1.3.5",
12
+ "@zhin.js/adapter": "1.1.1",
13
+ "@zhin.js/core": "1.4.1",
14
14
  "@zhin.js/logger": "1.0.75",
15
- "@zhin.js/plugin-runtime": "1.0.1"
15
+ "@zhin.js/plugin-runtime": "1.1.1"
16
16
  },
17
17
  "peerDependencies": {
18
- "@zhin.js/adapter": "1.0.1",
19
- "@zhin.js/core": "1.3.5",
20
- "@zhin.js/plugin-runtime": "1.0.1",
21
- "zhin.js": "4.1.3"
18
+ "@zhin.js/adapter": "1.1.1",
19
+ "@zhin.js/core": "1.4.1",
20
+ "@zhin.js/plugin-runtime": "1.1.1",
21
+ "zhin.js": "5.0.1"
22
22
  },
23
23
  "peerDependenciesMeta": {
24
24
  "zhin.js": {
package/schema.json CHANGED
@@ -3,56 +3,131 @@
3
3
  "type": "object",
4
4
  "additionalProperties": false,
5
5
  "properties": {
6
- "name": {
7
- "type": "string",
8
- "default": "email-bot"
9
- },
10
- "smtp": {
11
- "type": "object",
12
- "additionalProperties": false,
13
- "properties": {
14
- "host": { "type": "string" },
15
- "port": { "type": "number" },
16
- "secure": { "type": "boolean" },
17
- "auth": {
18
- "type": "object",
19
- "additionalProperties": false,
20
- "properties": {
21
- "user": { "type": "string" },
22
- "pass": { "type": "string" }
6
+ "endpoints": {
7
+ "type": "array",
8
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
9
+ "items": {
10
+ "type": "object",
11
+ "additionalProperties": true,
12
+ "properties": {
13
+ "name": {
14
+ "type": "string",
15
+ "description": "Email bot name"
23
16
  },
24
- "required": ["user", "pass"]
25
- }
26
- },
27
- "required": ["host", "port", "secure", "auth"]
28
- },
29
- "imap": {
30
- "type": "object",
31
- "additionalProperties": false,
32
- "properties": {
33
- "host": { "type": "string" },
34
- "port": { "type": "number" },
35
- "tls": { "type": "boolean" },
36
- "user": { "type": "string" },
37
- "password": { "type": "string" },
38
- "checkInterval": { "type": "number", "default": 60000 },
39
- "mailbox": { "type": "string", "default": "INBOX" },
40
- "markSeen": { "type": "boolean", "default": true }
41
- },
42
- "required": ["host", "port", "tls", "user", "password"]
43
- },
44
- "attachments": {
45
- "type": "object",
46
- "additionalProperties": false,
47
- "properties": {
48
- "enabled": { "type": "boolean", "default": false },
49
- "downloadPath": { "type": "string" },
50
- "maxFileSize": { "type": "number" },
51
- "allowedTypes": {
52
- "type": "array",
53
- "items": { "type": "string" }
54
- }
17
+ "smtp": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "properties": {
21
+ "host": {
22
+ "type": "string"
23
+ },
24
+ "port": {
25
+ "type": "number"
26
+ },
27
+ "secure": {
28
+ "type": "boolean"
29
+ },
30
+ "auth": {
31
+ "type": "object",
32
+ "additionalProperties": false,
33
+ "properties": {
34
+ "user": {
35
+ "type": "string"
36
+ },
37
+ "pass": {
38
+ "type": "string"
39
+ }
40
+ },
41
+ "required": [
42
+ "user",
43
+ "pass"
44
+ ]
45
+ }
46
+ },
47
+ "required": [
48
+ "host",
49
+ "port",
50
+ "secure",
51
+ "auth"
52
+ ]
53
+ },
54
+ "imap": {
55
+ "type": "object",
56
+ "additionalProperties": false,
57
+ "properties": {
58
+ "host": {
59
+ "type": "string"
60
+ },
61
+ "port": {
62
+ "type": "number"
63
+ },
64
+ "tls": {
65
+ "type": "boolean"
66
+ },
67
+ "user": {
68
+ "type": "string"
69
+ },
70
+ "password": {
71
+ "type": "string"
72
+ },
73
+ "checkInterval": {
74
+ "type": "number",
75
+ "default": 60000
76
+ },
77
+ "mailbox": {
78
+ "type": "string",
79
+ "default": "INBOX"
80
+ },
81
+ "markSeen": {
82
+ "type": "boolean",
83
+ "default": true
84
+ }
85
+ },
86
+ "required": [
87
+ "host",
88
+ "port",
89
+ "tls",
90
+ "user",
91
+ "password"
92
+ ]
93
+ },
94
+ "attachments": {
95
+ "type": "object",
96
+ "additionalProperties": false,
97
+ "properties": {
98
+ "enabled": {
99
+ "type": "boolean",
100
+ "default": false
101
+ },
102
+ "downloadPath": {
103
+ "type": "string"
104
+ },
105
+ "maxFileSize": {
106
+ "type": "number"
107
+ },
108
+ "allowedTypes": {
109
+ "type": "array",
110
+ "items": {
111
+ "type": "string"
112
+ }
113
+ }
114
+ }
115
+ }
116
+ },
117
+ "required": [
118
+ "name",
119
+ "smtp",
120
+ "imap"
121
+ ]
55
122
  }
123
+ },
124
+ "commandPrefix": {
125
+ "type": "string",
126
+ "default": "",
127
+ "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
56
128
  }
57
- }
129
+ },
130
+ "required": [
131
+ "endpoints"
132
+ ]
58
133
  }
package/src/endpoint.ts CHANGED
@@ -10,11 +10,13 @@ import { formatCompact, getLogger } from '@zhin.js/logger';
10
10
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
11
11
  import {
12
12
  formatInboundContent,
13
+ formatInboundSegments,
13
14
  formatOutboundMail,
14
15
  parseEmailMessage,
15
16
  senderDisplayName,
16
17
  type EmailMessage,
17
18
  type ResolvedEmailConfig,
19
+ type SavedEmailAttachment,
18
20
  } from './protocol.js';
19
21
  import {
20
22
  defaultCreateImap,
@@ -34,11 +36,18 @@ export interface EmailEndpointOptions {
34
36
  readonly createImap?: (config: ResolvedEmailConfig['imap']) => EmailImapTransport;
35
37
  }
36
38
 
39
+ /**
40
+ * Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
41
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
42
+ */
37
43
  export class EmailEndpoint implements EndpointInstance {
38
44
  readonly #options: EmailEndpointOptions;
39
45
  #smtp: EmailSmtpTransport | null = null;
40
46
  #imap: EmailImapTransport | null = null;
41
47
  #checkTimer: NodeJS.Timeout | null = null;
48
+ #reconnectTimer: NodeJS.Timeout | null = null;
49
+ #reconnectAttempts = 0;
50
+ #checking = false;
42
51
  #open = false;
43
52
  #started = false;
44
53
 
@@ -63,6 +72,7 @@ export class EmailEndpoint implements EndpointInstance {
63
72
  this.#imap!.connect();
64
73
  });
65
74
  logger.debug(formatCompact({ endpoint: name, mode: 'imap' }));
75
+ this.#reconnectAttempts = 0;
66
76
  this.#startEmailCheck();
67
77
  } catch (error) {
68
78
  await this.stop();
@@ -81,10 +91,16 @@ export class EmailEndpoint implements EndpointInstance {
81
91
 
82
92
  async stop(): Promise<void> {
83
93
  this.#open = false;
94
+ // 先复位 #started,避免 imap.end() 触发的 'end' 事件又武装重连定时器
95
+ this.#started = false;
84
96
  if (this.#checkTimer) {
85
97
  clearInterval(this.#checkTimer);
86
98
  this.#checkTimer = null;
87
99
  }
100
+ if (this.#reconnectTimer) {
101
+ clearTimeout(this.#reconnectTimer);
102
+ this.#reconnectTimer = null;
103
+ }
88
104
  if (this.#imap) {
89
105
  try {
90
106
  this.#imap.end();
@@ -101,7 +117,6 @@ export class EmailEndpoint implements EndpointInstance {
101
117
  }
102
118
  this.#smtp = null;
103
119
  }
104
- this.#started = false;
105
120
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
106
121
  }
107
122
 
@@ -136,6 +151,7 @@ export class EmailEndpoint implements EndpointInstance {
136
151
  adapter: this.#options.id,
137
152
  target: sender,
138
153
  content,
154
+ segments: formatInboundSegments(email, savedAttachments),
139
155
  sender: senderDisplayName(sender),
140
156
  id: email.messageId || undefined,
141
157
  metadata: Object.freeze({
@@ -152,17 +168,25 @@ export class EmailEndpoint implements EndpointInstance {
152
168
 
153
169
  /**
154
170
  * attachments.enabled 时把入站附件落盘(恢复旧 downloadAttachment 行为,
155
- * 附加 maxFileSize / allowedTypes 过滤);返回落盘结果供 admit metadata 使用。
171
+ * 附加 maxFileSize / allowedTypes 过滤);返回落盘结果供 admit segments/metadata 使用。
156
172
  */
157
173
  async #downloadAttachments(
158
174
  email: EmailMessage,
159
- ): Promise<Array<{ filename: string; path: string; contentType?: string; size?: number }>> {
175
+ ): Promise<SavedEmailAttachment[]> {
160
176
  const config = this.#options.config.attachments;
161
177
  if (!config?.enabled || email.attachments.length === 0) return [];
162
178
  await mkdir(config.downloadPath, { recursive: true });
163
- const saved: Array<{ filename: string; path: string; contentType?: string; size?: number }> = [];
179
+ const downloadRoot = path.resolve(config.downloadPath);
180
+ const saved: SavedEmailAttachment[] = [];
164
181
  for (const attachment of email.attachments) {
165
- const filename = attachment.filename || `attachment_${Date.now()}`;
182
+ // 防路径穿越:发件人可构造 ../../ 等文件名,basename + resolve 后必须落在 downloadPath 内
183
+ const rawName = attachment.filename || `attachment_${Date.now()}`;
184
+ const filename = path.basename(rawName) || `attachment_${Date.now()}`;
185
+ const filepath = path.resolve(downloadRoot, filename);
186
+ if (filepath !== downloadRoot && !filepath.startsWith(downloadRoot + path.sep)) {
187
+ logger.warn(formatCompact({ op: 'email_attachment_skipped', filename: rawName, reason: 'path' }));
188
+ continue;
189
+ }
166
190
  if (config.allowedTypes?.length && !config.allowedTypes.includes(attachment.contentType ?? '')) {
167
191
  logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'type' }));
168
192
  continue;
@@ -171,7 +195,6 @@ export class EmailEndpoint implements EndpointInstance {
171
195
  logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'size' }));
172
196
  continue;
173
197
  }
174
- const filepath = path.join(config.downloadPath, filename);
175
198
  try {
176
199
  await writeFile(filepath, attachment.content);
177
200
  saved.push({ filename, path: filepath, contentType: attachment.contentType, size: attachment.size });
@@ -192,6 +215,8 @@ export class EmailEndpoint implements EndpointInstance {
192
215
  });
193
216
  imap.on('error', (error) => {
194
217
  logger.error('IMAP error:', error);
218
+ // imap 通常在 error 后紧跟 end;两处都调度,靠已有定时器去重
219
+ this.#scheduleImapReconnect();
195
220
  });
196
221
  imap.on('end', () => {
197
222
  logger.debug(formatCompact({
@@ -199,9 +224,57 @@ export class EmailEndpoint implements EndpointInstance {
199
224
  endpoint: this.#options.config.name,
200
225
  mode: 'imap',
201
226
  }));
227
+ this.#scheduleImapReconnect();
202
228
  });
203
229
  }
204
230
 
231
+ /** IMAP 断线后按指数退避重建连接并恢复监听(基数 reconnectInterval,封顶 5 分钟)。 */
232
+ #scheduleImapReconnect(): void {
233
+ if (!this.#started || this.#reconnectTimer) return;
234
+ const base = this.#options.config.imap.reconnectInterval;
235
+ const delay = Math.min(base * 2 ** this.#reconnectAttempts, 300_000);
236
+ this.#reconnectAttempts += 1;
237
+ logger.warn(formatCompact({
238
+ op: 'imap_reconnect_scheduled',
239
+ endpoint: this.#options.config.name,
240
+ reconnect_ms: delay,
241
+ }));
242
+ this.#reconnectTimer = setTimeout(() => {
243
+ this.#reconnectTimer = null;
244
+ void this.#reconnectImap();
245
+ }, delay);
246
+ }
247
+
248
+ async #reconnectImap(): Promise<void> {
249
+ if (!this.#started) return;
250
+ try {
251
+ const imap = this.#options.createImap?.(this.#options.config.imap)
252
+ ?? defaultCreateImap(this.#options.config.imap);
253
+ this.#imap = imap;
254
+ this.#setupImapListeners(imap);
255
+ await new Promise<void>((resolve, reject) => {
256
+ imap.once('ready', () => resolve());
257
+ imap.once('error', (error) => reject(error));
258
+ imap.connect();
259
+ });
260
+ this.#reconnectAttempts = 0;
261
+ logger.info(formatCompact({
262
+ op: 'imap_reconnect',
263
+ endpoint: this.#options.config.name,
264
+ ok: true,
265
+ }));
266
+ void this.#checkForNewEmails();
267
+ } catch (error) {
268
+ logger.warn(formatCompact({
269
+ op: 'imap_reconnect',
270
+ endpoint: this.#options.config.name,
271
+ ok: false,
272
+ error: error instanceof Error ? error.message : String(error),
273
+ }));
274
+ this.#scheduleImapReconnect();
275
+ }
276
+ }
277
+
205
278
  #startEmailCheck(): void {
206
279
  if (this.#checkTimer) return;
207
280
  this.#checkTimer = setInterval(() => {
@@ -211,7 +284,9 @@ export class EmailEndpoint implements EndpointInstance {
211
284
  }
212
285
 
213
286
  async #checkForNewEmails(): Promise<void> {
214
- if (!this.#imap || !this.#started) return;
287
+ if (!this.#imap || !this.#started || this.#checking) return;
288
+ // 在飞锁:定时器与 mail 事件可能并发触发,串行化避免重复 admit
289
+ this.#checking = true;
215
290
  try {
216
291
  await new Promise<void>((resolve, reject) => {
217
292
  this.#imap!.openBox(this.#options.config.imap.mailbox, false, (error) => {
@@ -233,6 +308,8 @@ export class EmailEndpoint implements EndpointInstance {
233
308
  });
234
309
  } catch (error) {
235
310
  logger.error('Error checking for new emails:', error);
311
+ } finally {
312
+ this.#checking = false;
236
313
  }
237
314
  }
238
315
 
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  addressListText,
3
3
  formatInboundContent,
4
+ formatInboundSegments,
4
5
  formatOutboundMail,
5
6
  htmlToText,
6
7
  parseEmailMessage,
@@ -12,6 +13,7 @@ export {
12
13
  type EmailWireSegment,
13
14
  type ImapConfig,
14
15
  type ResolvedEmailConfig,
16
+ type SavedEmailAttachment,
15
17
  type SmtpConfig,
16
18
  } from './protocol.js';
17
19
 
package/src/protocol.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  import type { Attachment } from 'mailparser';
7
- import { htmlToPlainTextWithBlockBreaks } from '@zhin.js/core';
7
+ import { htmlToPlainTextWithBlockBreaks, isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
8
+ import type { Segment } from '@zhin.js/core/runtime';
8
9
 
9
10
  export interface SmtpConfig {
10
11
  readonly host: string;
@@ -23,6 +24,8 @@ export interface ImapConfig {
23
24
  readonly user: string;
24
25
  readonly password: string;
25
26
  readonly checkInterval?: number;
27
+ /** IMAP 断线重连基础间隔(指数退避基数),毫秒。 */
28
+ readonly reconnectInterval?: number;
26
29
  readonly mailbox?: string;
27
30
  readonly markSeen?: boolean;
28
31
  }
@@ -50,7 +53,7 @@ export interface ResolvedEmailConfig {
50
53
  readonly context: 'email';
51
54
  readonly name: string;
52
55
  readonly smtp: SmtpConfig;
53
- readonly imap: Required<Pick<ImapConfig, 'checkInterval' | 'mailbox' | 'markSeen'>> & ImapConfig;
56
+ readonly imap: Required<Pick<ImapConfig, 'checkInterval' | 'reconnectInterval' | 'mailbox' | 'markSeen'>> & ImapConfig;
54
57
  readonly attachments?: {
55
58
  readonly enabled: boolean;
56
59
  readonly downloadPath: string;
@@ -96,7 +99,7 @@ export function resolveEmailConfig(config: EmailAdapterConfig = {}): ResolvedEma
96
99
  ? {
97
100
  enabled: true as const,
98
101
  downloadPath: attachmentsSource.downloadPath || './downloads/email',
99
- maxFileSize: attachmentsSource.maxFileSize || 10 * 1024 * 1024,
102
+ maxFileSize: Math.max(attachmentsSource.maxFileSize || 10 * 1024 * 1024, 1),
100
103
  allowedTypes: attachmentsSource.allowedTypes,
101
104
  }
102
105
  : undefined;
@@ -106,7 +109,9 @@ export function resolveEmailConfig(config: EmailAdapterConfig = {}): ResolvedEma
106
109
  smtp,
107
110
  imap: {
108
111
  ...imap,
109
- checkInterval: imap.checkInterval ?? 60_000,
112
+ // 数值下限:0/负数会导致 setInterval(0) 风暴
113
+ checkInterval: Math.max(imap.checkInterval ?? 60_000, 1_000),
114
+ reconnectInterval: Math.max(imap.reconnectInterval ?? 5_000, 1_000),
110
115
  mailbox: imap.mailbox ?? 'INBOX',
111
116
  markSeen: imap.markSeen !== false,
112
117
  },
@@ -181,6 +186,44 @@ export function formatInboundContent(email: EmailMessage): string {
181
186
  return text || '(Empty email)';
182
187
  }
183
188
 
189
+ /** 已落盘的入站附件(attachments.enabled 下载结果)。 */
190
+ export interface SavedEmailAttachment {
191
+ readonly filename: string;
192
+ readonly path: string;
193
+ readonly contentType?: string;
194
+ readonly size?: number;
195
+ }
196
+
197
+ /**
198
+ * 入站邮件 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
199
+ * 已落盘附件映射为 image/file 段,MediaRef kind=path 指向下载路径;
200
+ * 未下载的附件(disabled / 被过滤)只保留 content 里的占位文本。
201
+ */
202
+ export function formatInboundSegments(
203
+ email: EmailMessage,
204
+ savedAttachments: readonly SavedEmailAttachment[] = [],
205
+ ): Segment[] {
206
+ const out: Segment[] = [];
207
+ const content = formatInboundContent(email);
208
+ if (content) out.push({ type: 'text', data: { text: content } });
209
+ for (const saved of savedAttachments) {
210
+ const type = saved.contentType?.startsWith('image/') ? 'image' : 'file';
211
+ out.push({
212
+ type,
213
+ data: {
214
+ media: {
215
+ kind: 'path',
216
+ value: saved.path,
217
+ ...(saved.contentType ? { mime_type: saved.contentType } : {}),
218
+ },
219
+ name: saved.filename,
220
+ ...(type === 'image' ? { alt: saved.filename } : {}),
221
+ },
222
+ });
223
+ }
224
+ return out;
225
+ }
226
+
184
227
  export function senderDisplayName(from: string): string {
185
228
  const name = from.split('<')[0]?.trim();
186
229
  return name || from;
@@ -246,10 +289,15 @@ export function formatOutboundMail(
246
289
  }
247
290
  case 'image':
248
291
  case 'file': {
249
- if (typeof data.url === 'string' && data.url) {
292
+ // canonical MediaRef 优先(kind url/path 均可作 nodemailer attachment.path),
293
+ // 旧 wire 字段 `{url,file,base64}` 经 mediaRefFromLegacyData 兼容。
294
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
295
+ if (media && (media.kind === 'url' || media.kind === 'path')) {
250
296
  attachments.push({
251
- filename: String(data.filename || (item.type === 'image' ? 'image.png' : 'file')),
252
- path: data.url,
297
+ filename: String(
298
+ data.filename || data.name || (item.type === 'image' ? 'image.png' : 'file'),
299
+ ),
300
+ path: media.value,
253
301
  });
254
302
  }
255
303
  break;