@zhin.js/adapter-email 5.0.1 → 5.0.4
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 +47 -0
- package/README.md +2 -2
- package/lib/endpoint.d.ts +2 -5
- package/lib/endpoint.js +32 -31
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/protocol.d.ts +8 -2
- package/lib/protocol.js +14 -3
- package/package.json +10 -9
- package/schema.json +41 -5
- package/src/endpoint.ts +35 -33
- package/src/index.ts +1 -0
- package/src/protocol.ts +18 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# @zhin.js/adapter-email
|
|
2
2
|
|
|
3
|
+
## 5.0.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [c106ecc]
|
|
8
|
+
- Updated dependencies [b0f37ae]
|
|
9
|
+
- Updated dependencies [ba08a2f]
|
|
10
|
+
- Updated dependencies [daffd4c]
|
|
11
|
+
- Updated dependencies [36c7400]
|
|
12
|
+
- Updated dependencies [162fa34]
|
|
13
|
+
- Updated dependencies [e40b048]
|
|
14
|
+
- Updated dependencies [f1708c3]
|
|
15
|
+
- Updated dependencies [e53444f]
|
|
16
|
+
- Updated dependencies [92b0dd7]
|
|
17
|
+
- Updated dependencies [a7df753]
|
|
18
|
+
- @zhin.js/im-contract@1.0.3
|
|
19
|
+
- @zhin.js/adapter@1.1.7
|
|
20
|
+
- @zhin.js/plugin-runtime@1.1.5
|
|
21
|
+
- @zhin.js/core@1.5.4
|
|
22
|
+
- zhin.js@6.0.4
|
|
23
|
+
|
|
24
|
+
## 5.0.3
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- f8c7a54: fix: im
|
|
29
|
+
- Updated dependencies [f8c7a54]
|
|
30
|
+
- @zhin.js/logger@1.0.76
|
|
31
|
+
- @zhin.js/adapter@1.1.6
|
|
32
|
+
- @zhin.js/core@1.5.3
|
|
33
|
+
- @zhin.js/im-contract@1.0.2
|
|
34
|
+
- @zhin.js/plugin-runtime@1.1.4
|
|
35
|
+
- zhin.js@6.0.3
|
|
36
|
+
|
|
37
|
+
## 5.0.2
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- Updated dependencies [afc0e66]
|
|
42
|
+
- Updated dependencies [2e41ad5]
|
|
43
|
+
- Updated dependencies [9f57124]
|
|
44
|
+
- @zhin.js/core@1.5.2
|
|
45
|
+
- @zhin.js/adapter@1.1.5
|
|
46
|
+
- @zhin.js/im-contract@1.0.1
|
|
47
|
+
- @zhin.js/plugin-runtime@1.1.3
|
|
48
|
+
- zhin.js@6.0.2
|
|
49
|
+
|
|
3
50
|
## 5.0.1
|
|
4
51
|
|
|
5
52
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -23,8 +23,8 @@ pnpm add @zhin.js/adapter-email
|
|
|
23
23
|
- `@zhin.js/plugin-runtime` — `plugin.ts`(`definePlugin`)
|
|
24
24
|
- 配置经插件 `schema.json` 落到 `plugins.<instanceKey>`(`smtp` / `imap`)
|
|
25
25
|
|
|
26
|
-
入站:`gateway.receive({
|
|
27
|
-
出站:`send({
|
|
26
|
+
入站:`gateway.receive({ conversation, message, content: text, sender, metadata })`(`conversation` 为 kind=private、id=发件人地址的 ConversationRef)
|
|
27
|
+
出站:`send({ conversation, payload })` → nodemailer(收件人取 `conversation.id`;payload 已由 gateway/core 渲染;无 segment-mapper)
|
|
28
28
|
|
|
29
29
|
## 前置条件
|
|
30
30
|
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
1
|
+
import type { EndpointInstance, EndpointSendRequest } from '@zhin.js/adapter';
|
|
2
2
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
3
3
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
4
4
|
import { type EmailMessage, type ResolvedEmailConfig } from './protocol.js';
|
|
@@ -21,10 +21,7 @@ export declare class EmailEndpoint implements EndpointInstance {
|
|
|
21
21
|
open(): void;
|
|
22
22
|
close(): void;
|
|
23
23
|
stop(): Promise<void>;
|
|
24
|
-
send({
|
|
25
|
-
readonly target: string;
|
|
26
|
-
readonly payload: unknown;
|
|
27
|
-
}): Promise<string>;
|
|
24
|
+
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
28
25
|
/** Test / internal: admit a parsed mail when the endpoint is open. */
|
|
29
26
|
admit(email: EmailMessage): void;
|
|
30
27
|
}
|
package/lib/endpoint.js
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import { simpleParser } from 'mailparser';
|
|
7
|
-
import { formatCompact,
|
|
8
|
-
import { formatInboundContent, formatInboundSegments, formatOutboundMail, parseEmailMessage, senderDisplayName, } from './protocol.js';
|
|
7
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
8
|
+
import { emailInboundConversation, formatInboundContent, formatInboundSegments, formatOutboundMail, parseEmailMessage, senderDisplayName, } from './protocol.js';
|
|
9
9
|
import { defaultCreateImap, defaultCreateSmtp, } from './transport.js';
|
|
10
|
-
const logger = getLogger('email');
|
|
11
10
|
/**
|
|
12
11
|
* Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
|
|
13
12
|
* 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
|
|
14
13
|
*/
|
|
15
14
|
export class EmailEndpoint {
|
|
15
|
+
#logger;
|
|
16
16
|
#options;
|
|
17
17
|
#smtp = null;
|
|
18
18
|
#imap = null;
|
|
@@ -23,17 +23,18 @@ export class EmailEndpoint {
|
|
|
23
23
|
#open = false;
|
|
24
24
|
#started = false;
|
|
25
25
|
constructor(options) {
|
|
26
|
+
this.#logger = getAdapterLogger('email', options.config.id);
|
|
26
27
|
this.#options = options;
|
|
27
28
|
}
|
|
28
29
|
async start() {
|
|
29
30
|
if (this.#started)
|
|
30
31
|
return;
|
|
31
32
|
this.#started = true;
|
|
32
|
-
const { smtp, imap,
|
|
33
|
+
const { smtp, imap, id } = this.#options.config;
|
|
33
34
|
try {
|
|
34
35
|
this.#smtp = await (this.#options.createSmtp?.(smtp) ?? defaultCreateSmtp(smtp));
|
|
35
36
|
await this.#smtp.verify();
|
|
36
|
-
logger.debug(formatCompact({
|
|
37
|
+
this.#logger.debug(formatCompact({ mode: 'smtp' }));
|
|
37
38
|
this.#imap = this.#options.createImap?.(imap) ?? defaultCreateImap(imap);
|
|
38
39
|
this.#setupImapListeners(this.#imap);
|
|
39
40
|
await new Promise((resolve, reject) => {
|
|
@@ -41,13 +42,13 @@ export class EmailEndpoint {
|
|
|
41
42
|
this.#imap.once('error', (error) => reject(error));
|
|
42
43
|
this.#imap.connect();
|
|
43
44
|
});
|
|
44
|
-
logger.debug(formatCompact({
|
|
45
|
+
this.#logger.debug(formatCompact({ mode: 'imap' }));
|
|
45
46
|
this.#reconnectAttempts = 0;
|
|
46
47
|
this.#startEmailCheck();
|
|
47
48
|
}
|
|
48
49
|
catch (error) {
|
|
49
50
|
await this.stop();
|
|
50
|
-
logger.error('Failed to connect email services:', error);
|
|
51
|
+
this.#logger.error('Failed to connect email services:', error);
|
|
51
52
|
throw error;
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -87,17 +88,18 @@ export class EmailEndpoint {
|
|
|
87
88
|
}
|
|
88
89
|
this.#smtp = null;
|
|
89
90
|
}
|
|
90
|
-
logger.debug(formatCompact({ op: 'disconnect'
|
|
91
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
91
92
|
}
|
|
92
|
-
async send({
|
|
93
|
+
async send({ conversation, payload }) {
|
|
93
94
|
if (!this.#smtp)
|
|
94
95
|
throw new Error('SMTP transporter not initialized');
|
|
96
|
+
const target = conversation.id;
|
|
95
97
|
const mailOptions = formatOutboundMail(payload, {
|
|
96
98
|
from: this.#options.config.smtp.auth.user,
|
|
97
99
|
to: target,
|
|
98
100
|
});
|
|
99
101
|
const info = await this.#smtp.sendMail(mailOptions);
|
|
100
|
-
logger.debug(formatCompact({ op: 'email_send', target, messageId: info.messageId }));
|
|
102
|
+
this.#logger.debug(formatCompact({ op: 'email_send', target, messageId: info.messageId }));
|
|
101
103
|
return info.messageId || '';
|
|
102
104
|
}
|
|
103
105
|
/** Test / internal: admit a parsed mail when the endpoint is open. */
|
|
@@ -105,7 +107,7 @@ export class EmailEndpoint {
|
|
|
105
107
|
if (!this.#open)
|
|
106
108
|
return;
|
|
107
109
|
void this.#admitWithAttachments(email).catch((err) => {
|
|
108
|
-
logger.warn(formatCompact({
|
|
110
|
+
this.#logger.warn(formatCompact({
|
|
109
111
|
op: 'email_gateway_receive_failed',
|
|
110
112
|
target: email.from,
|
|
111
113
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -116,20 +118,20 @@ export class EmailEndpoint {
|
|
|
116
118
|
const savedAttachments = await this.#downloadAttachments(email);
|
|
117
119
|
const content = formatInboundContent(email);
|
|
118
120
|
const sender = email.from;
|
|
121
|
+
const conversation = emailInboundConversation(String(this.#options.id), email);
|
|
119
122
|
await this.#options.gateway.receive({
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
conversation,
|
|
124
|
+
...(email.messageId ? { message: { conversation, id: email.messageId } } : {}),
|
|
122
125
|
content,
|
|
123
126
|
segments: formatInboundSegments(email, savedAttachments),
|
|
124
|
-
sender: senderDisplayName(sender),
|
|
125
|
-
|
|
127
|
+
sender: { id: sender, name: senderDisplayName(sender) || undefined },
|
|
128
|
+
endpointId: this.#options.config.id,
|
|
126
129
|
metadata: Object.freeze({
|
|
127
130
|
subject: email.subject,
|
|
128
131
|
to: email.to,
|
|
129
132
|
cc: email.cc,
|
|
130
133
|
uid: email.uid,
|
|
131
134
|
date: email.date.toISOString(),
|
|
132
|
-
endpoint: this.#options.config.name,
|
|
133
135
|
...(savedAttachments.length ? { attachments: savedAttachments } : {}),
|
|
134
136
|
}),
|
|
135
137
|
});
|
|
@@ -151,15 +153,15 @@ export class EmailEndpoint {
|
|
|
151
153
|
const filename = path.basename(rawName) || `attachment_${Date.now()}`;
|
|
152
154
|
const filepath = path.resolve(downloadRoot, filename);
|
|
153
155
|
if (filepath !== downloadRoot && !filepath.startsWith(downloadRoot + path.sep)) {
|
|
154
|
-
logger.warn(formatCompact({ op: 'email_attachment_skipped', filename: rawName, reason: 'path' }));
|
|
156
|
+
this.#logger.warn(formatCompact({ op: 'email_attachment_skipped', filename: rawName, reason: 'path' }));
|
|
155
157
|
continue;
|
|
156
158
|
}
|
|
157
159
|
if (config.allowedTypes?.length && !config.allowedTypes.includes(attachment.contentType ?? '')) {
|
|
158
|
-
logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'type' }));
|
|
160
|
+
this.#logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'type' }));
|
|
159
161
|
continue;
|
|
160
162
|
}
|
|
161
163
|
if (attachment.size != null && attachment.size > config.maxFileSize) {
|
|
162
|
-
logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'size' }));
|
|
164
|
+
this.#logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'size' }));
|
|
163
165
|
continue;
|
|
164
166
|
}
|
|
165
167
|
try {
|
|
@@ -167,7 +169,7 @@ export class EmailEndpoint {
|
|
|
167
169
|
saved.push({ filename, path: filepath, contentType: attachment.contentType, size: attachment.size });
|
|
168
170
|
}
|
|
169
171
|
catch (error) {
|
|
170
|
-
logger.warn(formatCompact({
|
|
172
|
+
this.#logger.warn(formatCompact({
|
|
171
173
|
op: 'email_attachment_download_failed',
|
|
172
174
|
filename,
|
|
173
175
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -181,14 +183,13 @@ export class EmailEndpoint {
|
|
|
181
183
|
void this.#checkForNewEmails();
|
|
182
184
|
});
|
|
183
185
|
imap.on('error', (error) => {
|
|
184
|
-
logger.error('IMAP error:', error);
|
|
186
|
+
this.#logger.error('IMAP error:', error);
|
|
185
187
|
// imap 通常在 error 后紧跟 end;两处都调度,靠已有定时器去重
|
|
186
188
|
this.#scheduleImapReconnect();
|
|
187
189
|
});
|
|
188
190
|
imap.on('end', () => {
|
|
189
|
-
logger.debug(formatCompact({
|
|
191
|
+
this.#logger.debug(formatCompact({
|
|
190
192
|
op: 'disconnect',
|
|
191
|
-
endpoint: this.#options.config.name,
|
|
192
193
|
mode: 'imap',
|
|
193
194
|
}));
|
|
194
195
|
this.#scheduleImapReconnect();
|
|
@@ -201,9 +202,9 @@ export class EmailEndpoint {
|
|
|
201
202
|
const base = this.#options.config.imap.reconnectInterval;
|
|
202
203
|
const delay = Math.min(base * 2 ** this.#reconnectAttempts, 300_000);
|
|
203
204
|
this.#reconnectAttempts += 1;
|
|
204
|
-
logger.warn(formatCompact({
|
|
205
|
+
this.#logger.warn(formatCompact({
|
|
205
206
|
op: 'imap_reconnect_scheduled',
|
|
206
|
-
endpoint: this.#options.config.
|
|
207
|
+
endpoint: this.#options.config.id,
|
|
207
208
|
reconnect_ms: delay,
|
|
208
209
|
}));
|
|
209
210
|
this.#reconnectTimer = setTimeout(() => {
|
|
@@ -225,17 +226,17 @@ export class EmailEndpoint {
|
|
|
225
226
|
imap.connect();
|
|
226
227
|
});
|
|
227
228
|
this.#reconnectAttempts = 0;
|
|
228
|
-
logger.info(formatCompact({
|
|
229
|
+
this.#logger.info(formatCompact({
|
|
229
230
|
op: 'imap_reconnect',
|
|
230
|
-
endpoint: this.#options.config.
|
|
231
|
+
endpoint: this.#options.config.id,
|
|
231
232
|
ok: true,
|
|
232
233
|
}));
|
|
233
234
|
void this.#checkForNewEmails();
|
|
234
235
|
}
|
|
235
236
|
catch (error) {
|
|
236
|
-
logger.warn(formatCompact({
|
|
237
|
+
this.#logger.warn(formatCompact({
|
|
237
238
|
op: 'imap_reconnect',
|
|
238
|
-
endpoint: this.#options.config.
|
|
239
|
+
endpoint: this.#options.config.id,
|
|
239
240
|
ok: false,
|
|
240
241
|
error: error instanceof Error ? error.message : String(error),
|
|
241
242
|
}));
|
|
@@ -279,7 +280,7 @@ export class EmailEndpoint {
|
|
|
279
280
|
});
|
|
280
281
|
}
|
|
281
282
|
catch (error) {
|
|
282
|
-
logger.error('Error checking for new emails:', error);
|
|
283
|
+
this.#logger.error('Error checking for new emails:', error);
|
|
283
284
|
}
|
|
284
285
|
finally {
|
|
285
286
|
this.#checking = false;
|
|
@@ -300,7 +301,7 @@ export class EmailEndpoint {
|
|
|
300
301
|
void simpleParser(body).then((parsed) => {
|
|
301
302
|
this.admit(parseEmailMessage(parsed, uid));
|
|
302
303
|
}).catch((error) => {
|
|
303
|
-
logger.error('Error parsing email:', error);
|
|
304
|
+
this.#logger.error('Error parsing email:', error);
|
|
304
305
|
});
|
|
305
306
|
});
|
|
306
307
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
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';
|
|
1
|
+
export { addressListText, emailInboundConversation, 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, formatInboundSegments, formatOutboundMail, htmlToText, parseEmailMessage, resolveEmailConfig, senderDisplayName, } from './protocol.js';
|
|
1
|
+
export { addressListText, emailInboundConversation, 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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { Attachment } from 'mailparser';
|
|
6
6
|
import type { Segment } from '@zhin.js/core/runtime';
|
|
7
|
+
import type { ConversationRef } from '@zhin.js/im-contract';
|
|
7
8
|
export interface SmtpConfig {
|
|
8
9
|
readonly host: string;
|
|
9
10
|
readonly port: number;
|
|
@@ -33,7 +34,7 @@ export interface EmailAttachmentsConfig {
|
|
|
33
34
|
}
|
|
34
35
|
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
35
36
|
export interface EmailAdapterConfig {
|
|
36
|
-
readonly
|
|
37
|
+
readonly id?: string;
|
|
37
38
|
readonly smtp?: SmtpConfig;
|
|
38
39
|
readonly imap?: ImapConfig;
|
|
39
40
|
readonly attachments?: EmailAttachmentsConfig;
|
|
@@ -44,7 +45,7 @@ export interface EmailAdapterConfig {
|
|
|
44
45
|
}
|
|
45
46
|
export interface ResolvedEmailConfig {
|
|
46
47
|
readonly context: 'email';
|
|
47
|
-
readonly
|
|
48
|
+
readonly id: string;
|
|
48
49
|
readonly smtp: SmtpConfig;
|
|
49
50
|
readonly imap: Required<Pick<ImapConfig, 'checkInterval' | 'reconnectInterval' | 'mailbox' | 'markSeen'>> & ImapConfig;
|
|
50
51
|
readonly attachments?: {
|
|
@@ -101,6 +102,11 @@ export interface SavedEmailAttachment {
|
|
|
101
102
|
* 未下载的附件(disabled / 被过滤)只保留 content 里的占位文本。
|
|
102
103
|
*/
|
|
103
104
|
export declare function formatInboundSegments(email: EmailMessage, savedAttachments?: readonly SavedEmailAttachment[]): Segment[];
|
|
105
|
+
/**
|
|
106
|
+
* 入站归一化 → ConversationRef:Email 无群/频道概念,所有入站邮件都是
|
|
107
|
+
* 与发件人地址的 private 会话(id = 发件人地址)。
|
|
108
|
+
*/
|
|
109
|
+
export declare function emailInboundConversation(endpointKey: string, email: EmailMessage): ConversationRef;
|
|
104
110
|
export declare function senderDisplayName(from: string): string;
|
|
105
111
|
/**
|
|
106
112
|
* nodemailer 附件的最小形状:url/path 走 `path`(URL 由 nodemailer 拉流、
|
package/lib/protocol.js
CHANGED
|
@@ -12,8 +12,8 @@ export function resolveEmailConfig(config = {}) {
|
|
|
12
12
|
if (!smtp?.host || !smtp.auth?.user || !imap?.host || !imap.user) {
|
|
13
13
|
throw new TypeError('Email adapter requires smtp + imap config (plugins.<key>.smtp/imap or endpoints with context: email)');
|
|
14
14
|
}
|
|
15
|
-
const
|
|
16
|
-
|| (typeof entry?.
|
|
15
|
+
const id = (typeof config.id === 'string' && config.id)
|
|
16
|
+
|| (typeof entry?.id === 'string' && entry.id)
|
|
17
17
|
|| process.env.EMAIL_BOT_NAME
|
|
18
18
|
|| 'email-bot';
|
|
19
19
|
const attachmentsSource = config.attachments ?? entry?.attachments;
|
|
@@ -27,7 +27,7 @@ export function resolveEmailConfig(config = {}) {
|
|
|
27
27
|
: undefined;
|
|
28
28
|
return {
|
|
29
29
|
context: 'email',
|
|
30
|
-
|
|
30
|
+
id,
|
|
31
31
|
smtp,
|
|
32
32
|
imap: {
|
|
33
33
|
...imap,
|
|
@@ -120,6 +120,17 @@ export function formatInboundSegments(email, savedAttachments = []) {
|
|
|
120
120
|
}
|
|
121
121
|
return out;
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* 入站归一化 → ConversationRef:Email 无群/频道概念,所有入站邮件都是
|
|
125
|
+
* 与发件人地址的 private 会话(id = 发件人地址)。
|
|
126
|
+
*/
|
|
127
|
+
export function emailInboundConversation(endpointKey, email) {
|
|
128
|
+
return {
|
|
129
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
130
|
+
kind: 'private',
|
|
131
|
+
id: email.from,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
123
134
|
export function senderDisplayName(from) {
|
|
124
135
|
const name = from.split('<')[0]?.trim();
|
|
125
136
|
return name || from;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-email",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.4",
|
|
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,17 @@
|
|
|
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.5.
|
|
14
|
-
"@zhin.js/
|
|
15
|
-
"@zhin.js/
|
|
12
|
+
"@zhin.js/adapter": "1.1.7",
|
|
13
|
+
"@zhin.js/core": "1.5.4",
|
|
14
|
+
"@zhin.js/im-contract": "1.0.3",
|
|
15
|
+
"@zhin.js/logger": "1.0.76",
|
|
16
|
+
"@zhin.js/plugin-runtime": "1.1.5"
|
|
16
17
|
},
|
|
17
18
|
"peerDependencies": {
|
|
18
|
-
"@zhin.js/adapter": "1.1.
|
|
19
|
-
"@zhin.js/core": "1.5.
|
|
20
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
21
|
-
"zhin.js": "6.0.
|
|
19
|
+
"@zhin.js/adapter": "1.1.7",
|
|
20
|
+
"@zhin.js/core": "1.5.4",
|
|
21
|
+
"@zhin.js/plugin-runtime": "1.1.5",
|
|
22
|
+
"zhin.js": "6.0.4"
|
|
22
23
|
},
|
|
23
24
|
"peerDependenciesMeta": {
|
|
24
25
|
"zhin.js": {
|
package/schema.json
CHANGED
|
@@ -3,16 +3,48 @@
|
|
|
3
3
|
"type": "object",
|
|
4
4
|
"additionalProperties": false,
|
|
5
5
|
"properties": {
|
|
6
|
+
"master": {
|
|
7
|
+
"type": [
|
|
8
|
+
"string",
|
|
9
|
+
"number"
|
|
10
|
+
],
|
|
11
|
+
"description": "框架 master(email address;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖"
|
|
12
|
+
},
|
|
13
|
+
"trusted": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": {
|
|
16
|
+
"type": [
|
|
17
|
+
"string",
|
|
18
|
+
"number"
|
|
19
|
+
],
|
|
20
|
+
"description": "Trusted email address"
|
|
21
|
+
},
|
|
22
|
+
"description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加"
|
|
23
|
+
},
|
|
6
24
|
"endpoints": {
|
|
7
25
|
"type": "array",
|
|
8
|
-
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(
|
|
26
|
+
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
|
|
9
27
|
"items": {
|
|
10
28
|
"type": "object",
|
|
11
29
|
"additionalProperties": true,
|
|
12
30
|
"properties": {
|
|
13
|
-
"
|
|
14
|
-
"type":
|
|
15
|
-
|
|
31
|
+
"master": {
|
|
32
|
+
"type": [
|
|
33
|
+
"string",
|
|
34
|
+
"number"
|
|
35
|
+
],
|
|
36
|
+
"description": "本 endpoint 的框架 master(email address);覆盖顶层 master"
|
|
37
|
+
},
|
|
38
|
+
"trusted": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": {
|
|
41
|
+
"type": [
|
|
42
|
+
"string",
|
|
43
|
+
"number"
|
|
44
|
+
],
|
|
45
|
+
"description": "Trusted email address"
|
|
46
|
+
},
|
|
47
|
+
"description": "本 endpoint 的 trusted 列表"
|
|
16
48
|
},
|
|
17
49
|
"smtp": {
|
|
18
50
|
"type": "object",
|
|
@@ -112,10 +144,14 @@
|
|
|
112
144
|
}
|
|
113
145
|
}
|
|
114
146
|
}
|
|
147
|
+
},
|
|
148
|
+
"id": {
|
|
149
|
+
"type": "string",
|
|
150
|
+
"description": "Email bot name"
|
|
115
151
|
}
|
|
116
152
|
},
|
|
117
153
|
"required": [
|
|
118
|
-
"
|
|
154
|
+
"id",
|
|
119
155
|
"smtp",
|
|
120
156
|
"imap"
|
|
121
157
|
]
|
package/src/endpoint.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import { simpleParser } from 'mailparser';
|
|
7
|
-
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
7
|
+
import type { EndpointInstance, EndpointSendRequest } from '@zhin.js/adapter';
|
|
8
8
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
9
|
-
import { formatCompact,
|
|
9
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
10
10
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
11
11
|
import {
|
|
12
|
+
emailInboundConversation,
|
|
12
13
|
formatInboundContent,
|
|
13
14
|
formatInboundSegments,
|
|
14
15
|
formatOutboundMail,
|
|
@@ -26,8 +27,6 @@ import {
|
|
|
26
27
|
type EmailSmtpTransport,
|
|
27
28
|
} from './transport.js';
|
|
28
29
|
|
|
29
|
-
const logger = getLogger('email');
|
|
30
|
-
|
|
31
30
|
export interface EmailEndpointOptions {
|
|
32
31
|
readonly id: CapabilityId;
|
|
33
32
|
readonly gateway: MessageGateway;
|
|
@@ -41,6 +40,8 @@ export interface EmailEndpointOptions {
|
|
|
41
40
|
* 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
|
|
42
41
|
*/
|
|
43
42
|
export class EmailEndpoint implements EndpointInstance {
|
|
43
|
+
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
44
|
+
|
|
44
45
|
readonly #options: EmailEndpointOptions;
|
|
45
46
|
#smtp: EmailSmtpTransport | null = null;
|
|
46
47
|
#imap: EmailImapTransport | null = null;
|
|
@@ -52,17 +53,18 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
52
53
|
#started = false;
|
|
53
54
|
|
|
54
55
|
constructor(options: EmailEndpointOptions) {
|
|
56
|
+
this.#logger = getAdapterLogger('email', options.config.id);
|
|
55
57
|
this.#options = options;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
async start(): Promise<void> {
|
|
59
61
|
if (this.#started) return;
|
|
60
62
|
this.#started = true;
|
|
61
|
-
const { smtp, imap,
|
|
63
|
+
const { smtp, imap, id } = this.#options.config;
|
|
62
64
|
try {
|
|
63
65
|
this.#smtp = await (this.#options.createSmtp?.(smtp) ?? defaultCreateSmtp(smtp));
|
|
64
66
|
await this.#smtp.verify();
|
|
65
|
-
logger.debug(formatCompact({
|
|
67
|
+
this.#logger.debug(formatCompact({ mode: 'smtp' }));
|
|
66
68
|
|
|
67
69
|
this.#imap = this.#options.createImap?.(imap) ?? defaultCreateImap(imap);
|
|
68
70
|
this.#setupImapListeners(this.#imap);
|
|
@@ -71,12 +73,12 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
71
73
|
this.#imap!.once('error', (error) => reject(error));
|
|
72
74
|
this.#imap!.connect();
|
|
73
75
|
});
|
|
74
|
-
logger.debug(formatCompact({
|
|
76
|
+
this.#logger.debug(formatCompact({ mode: 'imap' }));
|
|
75
77
|
this.#reconnectAttempts = 0;
|
|
76
78
|
this.#startEmailCheck();
|
|
77
79
|
} catch (error) {
|
|
78
80
|
await this.stop();
|
|
79
|
-
logger.error('Failed to connect email services:', error);
|
|
81
|
+
this.#logger.error('Failed to connect email services:', error);
|
|
80
82
|
throw error;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
@@ -117,17 +119,18 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
117
119
|
}
|
|
118
120
|
this.#smtp = null;
|
|
119
121
|
}
|
|
120
|
-
logger.debug(formatCompact({ op: 'disconnect'
|
|
122
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
async send({
|
|
125
|
+
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
124
126
|
if (!this.#smtp) throw new Error('SMTP transporter not initialized');
|
|
127
|
+
const target = conversation.id;
|
|
125
128
|
const mailOptions = formatOutboundMail(payload, {
|
|
126
129
|
from: this.#options.config.smtp.auth.user,
|
|
127
130
|
to: target,
|
|
128
131
|
});
|
|
129
132
|
const info = await this.#smtp.sendMail(mailOptions);
|
|
130
|
-
logger.debug(formatCompact({ op: 'email_send', target, messageId: info.messageId }));
|
|
133
|
+
this.#logger.debug(formatCompact({ op: 'email_send', target, messageId: info.messageId }));
|
|
131
134
|
return info.messageId || '';
|
|
132
135
|
}
|
|
133
136
|
|
|
@@ -135,7 +138,7 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
135
138
|
admit(email: EmailMessage): void {
|
|
136
139
|
if (!this.#open) return;
|
|
137
140
|
void this.#admitWithAttachments(email).catch((err) => {
|
|
138
|
-
logger.warn(formatCompact({
|
|
141
|
+
this.#logger.warn(formatCompact({
|
|
139
142
|
op: 'email_gateway_receive_failed',
|
|
140
143
|
target: email.from,
|
|
141
144
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -147,20 +150,20 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
147
150
|
const savedAttachments = await this.#downloadAttachments(email);
|
|
148
151
|
const content = formatInboundContent(email);
|
|
149
152
|
const sender = email.from;
|
|
153
|
+
const conversation = emailInboundConversation(String(this.#options.id), email);
|
|
150
154
|
await this.#options.gateway.receive({
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
conversation,
|
|
156
|
+
...(email.messageId ? { message: { conversation, id: email.messageId } } : {}),
|
|
153
157
|
content,
|
|
154
158
|
segments: formatInboundSegments(email, savedAttachments),
|
|
155
|
-
sender: senderDisplayName(sender),
|
|
156
|
-
|
|
159
|
+
sender: { id: sender, name: senderDisplayName(sender) || undefined },
|
|
160
|
+
endpointId: this.#options.config.id,
|
|
157
161
|
metadata: Object.freeze({
|
|
158
162
|
subject: email.subject,
|
|
159
163
|
to: email.to,
|
|
160
164
|
cc: email.cc,
|
|
161
165
|
uid: email.uid,
|
|
162
166
|
date: email.date.toISOString(),
|
|
163
|
-
endpoint: this.#options.config.name,
|
|
164
167
|
...(savedAttachments.length ? { attachments: savedAttachments } : {}),
|
|
165
168
|
}),
|
|
166
169
|
});
|
|
@@ -184,22 +187,22 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
184
187
|
const filename = path.basename(rawName) || `attachment_${Date.now()}`;
|
|
185
188
|
const filepath = path.resolve(downloadRoot, filename);
|
|
186
189
|
if (filepath !== downloadRoot && !filepath.startsWith(downloadRoot + path.sep)) {
|
|
187
|
-
logger.warn(formatCompact({ op: 'email_attachment_skipped', filename: rawName, reason: 'path' }));
|
|
190
|
+
this.#logger.warn(formatCompact({ op: 'email_attachment_skipped', filename: rawName, reason: 'path' }));
|
|
188
191
|
continue;
|
|
189
192
|
}
|
|
190
193
|
if (config.allowedTypes?.length && !config.allowedTypes.includes(attachment.contentType ?? '')) {
|
|
191
|
-
logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'type' }));
|
|
194
|
+
this.#logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'type' }));
|
|
192
195
|
continue;
|
|
193
196
|
}
|
|
194
197
|
if (attachment.size != null && attachment.size > config.maxFileSize) {
|
|
195
|
-
logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'size' }));
|
|
198
|
+
this.#logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'size' }));
|
|
196
199
|
continue;
|
|
197
200
|
}
|
|
198
201
|
try {
|
|
199
202
|
await writeFile(filepath, attachment.content);
|
|
200
203
|
saved.push({ filename, path: filepath, contentType: attachment.contentType, size: attachment.size });
|
|
201
204
|
} catch (error) {
|
|
202
|
-
logger.warn(formatCompact({
|
|
205
|
+
this.#logger.warn(formatCompact({
|
|
203
206
|
op: 'email_attachment_download_failed',
|
|
204
207
|
filename,
|
|
205
208
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -214,14 +217,13 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
214
217
|
void this.#checkForNewEmails();
|
|
215
218
|
});
|
|
216
219
|
imap.on('error', (error) => {
|
|
217
|
-
logger.error('IMAP error:', error);
|
|
220
|
+
this.#logger.error('IMAP error:', error);
|
|
218
221
|
// imap 通常在 error 后紧跟 end;两处都调度,靠已有定时器去重
|
|
219
222
|
this.#scheduleImapReconnect();
|
|
220
223
|
});
|
|
221
224
|
imap.on('end', () => {
|
|
222
|
-
logger.debug(formatCompact({
|
|
223
|
-
|
|
224
|
-
endpoint: this.#options.config.name,
|
|
225
|
+
this.#logger.debug(formatCompact({
|
|
226
|
+
op: 'disconnect',
|
|
225
227
|
mode: 'imap',
|
|
226
228
|
}));
|
|
227
229
|
this.#scheduleImapReconnect();
|
|
@@ -234,9 +236,9 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
234
236
|
const base = this.#options.config.imap.reconnectInterval;
|
|
235
237
|
const delay = Math.min(base * 2 ** this.#reconnectAttempts, 300_000);
|
|
236
238
|
this.#reconnectAttempts += 1;
|
|
237
|
-
logger.warn(formatCompact({
|
|
239
|
+
this.#logger.warn(formatCompact({
|
|
238
240
|
op: 'imap_reconnect_scheduled',
|
|
239
|
-
endpoint: this.#options.config.
|
|
241
|
+
endpoint: this.#options.config.id,
|
|
240
242
|
reconnect_ms: delay,
|
|
241
243
|
}));
|
|
242
244
|
this.#reconnectTimer = setTimeout(() => {
|
|
@@ -258,16 +260,16 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
258
260
|
imap.connect();
|
|
259
261
|
});
|
|
260
262
|
this.#reconnectAttempts = 0;
|
|
261
|
-
logger.info(formatCompact({
|
|
263
|
+
this.#logger.info(formatCompact({
|
|
262
264
|
op: 'imap_reconnect',
|
|
263
|
-
endpoint: this.#options.config.
|
|
265
|
+
endpoint: this.#options.config.id,
|
|
264
266
|
ok: true,
|
|
265
267
|
}));
|
|
266
268
|
void this.#checkForNewEmails();
|
|
267
269
|
} catch (error) {
|
|
268
|
-
logger.warn(formatCompact({
|
|
270
|
+
this.#logger.warn(formatCompact({
|
|
269
271
|
op: 'imap_reconnect',
|
|
270
|
-
endpoint: this.#options.config.
|
|
272
|
+
endpoint: this.#options.config.id,
|
|
271
273
|
ok: false,
|
|
272
274
|
error: error instanceof Error ? error.message : String(error),
|
|
273
275
|
}));
|
|
@@ -307,7 +309,7 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
307
309
|
});
|
|
308
310
|
});
|
|
309
311
|
} catch (error) {
|
|
310
|
-
logger.error('Error checking for new emails:', error);
|
|
312
|
+
this.#logger.error('Error checking for new emails:', error);
|
|
311
313
|
} finally {
|
|
312
314
|
this.#checking = false;
|
|
313
315
|
}
|
|
@@ -328,7 +330,7 @@ export class EmailEndpoint implements EndpointInstance {
|
|
|
328
330
|
void simpleParser(body).then((parsed) => {
|
|
329
331
|
this.admit(parseEmailMessage(parsed, uid));
|
|
330
332
|
}).catch((error) => {
|
|
331
|
-
logger.error('Error parsing email:', error);
|
|
333
|
+
this.#logger.error('Error parsing email:', error);
|
|
332
334
|
});
|
|
333
335
|
});
|
|
334
336
|
}
|
package/src/index.ts
CHANGED
package/src/protocol.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { Attachment } from 'mailparser';
|
|
7
7
|
import { htmlToPlainTextWithBlockBreaks, isMediaRef, type MediaRef } from '@zhin.js/core';
|
|
8
8
|
import type { Segment } from '@zhin.js/core/runtime';
|
|
9
|
+
import type { ConversationRef } from '@zhin.js/im-contract';
|
|
9
10
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
10
11
|
|
|
11
12
|
const logger = getLogger('email');
|
|
@@ -42,7 +43,7 @@ export interface EmailAttachmentsConfig {
|
|
|
42
43
|
|
|
43
44
|
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
44
45
|
export interface EmailAdapterConfig {
|
|
45
|
-
readonly
|
|
46
|
+
readonly id?: string;
|
|
46
47
|
readonly smtp?: SmtpConfig;
|
|
47
48
|
readonly imap?: ImapConfig;
|
|
48
49
|
readonly attachments?: EmailAttachmentsConfig;
|
|
@@ -54,7 +55,7 @@ export interface EmailAdapterConfig {
|
|
|
54
55
|
|
|
55
56
|
export interface ResolvedEmailConfig {
|
|
56
57
|
readonly context: 'email';
|
|
57
|
-
readonly
|
|
58
|
+
readonly id: string;
|
|
58
59
|
readonly smtp: SmtpConfig;
|
|
59
60
|
readonly imap: Required<Pick<ImapConfig, 'checkInterval' | 'reconnectInterval' | 'mailbox' | 'markSeen'>> & ImapConfig;
|
|
60
61
|
readonly attachments?: {
|
|
@@ -93,8 +94,8 @@ export function resolveEmailConfig(config: EmailAdapterConfig = {}): ResolvedEma
|
|
|
93
94
|
'Email adapter requires smtp + imap config (plugins.<key>.smtp/imap or endpoints with context: email)',
|
|
94
95
|
);
|
|
95
96
|
}
|
|
96
|
-
const
|
|
97
|
-
|| (typeof entry?.
|
|
97
|
+
const id = (typeof config.id === 'string' && config.id)
|
|
98
|
+
|| (typeof entry?.id === 'string' && entry.id)
|
|
98
99
|
|| process.env.EMAIL_BOT_NAME
|
|
99
100
|
|| 'email-bot';
|
|
100
101
|
const attachmentsSource = config.attachments ?? entry?.attachments;
|
|
@@ -108,7 +109,7 @@ export function resolveEmailConfig(config: EmailAdapterConfig = {}): ResolvedEma
|
|
|
108
109
|
: undefined;
|
|
109
110
|
return {
|
|
110
111
|
context: 'email',
|
|
111
|
-
|
|
112
|
+
id,
|
|
112
113
|
smtp,
|
|
113
114
|
imap: {
|
|
114
115
|
...imap,
|
|
@@ -227,6 +228,18 @@ export function formatInboundSegments(
|
|
|
227
228
|
return out;
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
/**
|
|
232
|
+
* 入站归一化 → ConversationRef:Email 无群/频道概念,所有入站邮件都是
|
|
233
|
+
* 与发件人地址的 private 会话(id = 发件人地址)。
|
|
234
|
+
*/
|
|
235
|
+
export function emailInboundConversation(endpointKey: string, email: EmailMessage): ConversationRef {
|
|
236
|
+
return {
|
|
237
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
238
|
+
kind: 'private',
|
|
239
|
+
id: email.from,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
230
243
|
export function senderDisplayName(from: string): string {
|
|
231
244
|
const name = from.split('<')[0]?.trim();
|
|
232
245
|
return name || from;
|