@zhin.js/adapter-email 4.0.0 → 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 +29 -0
- package/adapters/email.ts +6 -0
- package/lib/endpoint.d.ts +4 -0
- package/lib/endpoint.js +83 -6
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/protocol.d.ts +17 -1
- package/lib/protocol.js +38 -6
- package/package.json +8 -8
- package/src/endpoint.ts +84 -7
- package/src/index.ts +2 -0
- package/src/protocol.ts +55 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
3
32
|
## 4.0.0
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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": "4.0.
|
|
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.1.
|
|
13
|
-
"@zhin.js/core": "1.4.
|
|
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.1.
|
|
15
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
18
|
-
"@zhin.js/adapter": "1.1.
|
|
19
|
-
"@zhin.js/core": "1.4.
|
|
20
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
21
|
-
"zhin.js": "5.0.
|
|
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/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<
|
|
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
|
|
179
|
+
const downloadRoot = path.resolve(config.downloadPath);
|
|
180
|
+
const saved: SavedEmailAttachment[] = [];
|
|
164
181
|
for (const attachment of email.attachments) {
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
252
|
-
|
|
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;
|