@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 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({ adapter, target: fromEmail, content: text, sender, metadata })`
27
- 出站:`send({ target, payload })` → nodemailerpayload 已由 gateway/core 渲染;无 segment-mapper)
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({ target, payload }: {
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, getLogger } from '@zhin.js/logger';
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, name } = this.#options.config;
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({ endpoint: name, mode: 'smtp' }));
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({ endpoint: name, mode: 'imap' }));
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', endpoint: this.#options.config.name }));
91
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
91
92
  }
92
- async send({ target, payload }) {
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
- adapter: this.#options.id,
121
- target: sender,
123
+ conversation,
124
+ ...(email.messageId ? { message: { conversation, id: email.messageId } } : {}),
122
125
  content,
123
126
  segments: formatInboundSegments(email, savedAttachments),
124
- sender: senderDisplayName(sender),
125
- id: email.messageId || undefined,
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.name,
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.name,
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.name,
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 name?: string;
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 name: string;
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 name = (typeof config.name === 'string' && config.name)
16
- || (typeof entry?.name === 'string' && entry.name)
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
- name,
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.1",
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.4",
13
- "@zhin.js/core": "1.5.1",
14
- "@zhin.js/logger": "1.0.75",
15
- "@zhin.js/plugin-runtime": "1.1.2"
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.4",
19
- "@zhin.js/core": "1.5.1",
20
- "@zhin.js/plugin-runtime": "1.1.2",
21
- "zhin.js": "6.0.1"
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。每项与顶层字段同构(name 必填,其余覆盖顶层)",
26
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
9
27
  "items": {
10
28
  "type": "object",
11
29
  "additionalProperties": true,
12
30
  "properties": {
13
- "name": {
14
- "type": "string",
15
- "description": "Email bot name"
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
- "name",
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, getLogger } from '@zhin.js/logger';
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, name } = this.#options.config;
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({ endpoint: name, mode: 'smtp' }));
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({ endpoint: name, mode: 'imap' }));
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', endpoint: this.#options.config.name }));
122
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
121
123
  }
122
124
 
123
- async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
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
- adapter: this.#options.id,
152
- target: sender,
155
+ conversation,
156
+ ...(email.messageId ? { message: { conversation, id: email.messageId } } : {}),
153
157
  content,
154
158
  segments: formatInboundSegments(email, savedAttachments),
155
- sender: senderDisplayName(sender),
156
- id: email.messageId || undefined,
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
- op: 'disconnect',
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.name,
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.name,
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.name,
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
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  addressListText,
3
+ emailInboundConversation,
3
4
  formatInboundContent,
4
5
  formatInboundSegments,
5
6
  formatOutboundMail,
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 name?: string;
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 name: string;
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 name = (typeof config.name === 'string' && config.name)
97
- || (typeof entry?.name === 'string' && entry.name)
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
- name,
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;