@zhin.js/adapter-email 6.0.0 → 6.0.2

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,31 @@
1
1
  # @zhin.js/adapter-email
2
2
 
3
+ ## 6.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [ba7e17a]
8
+ - Updated dependencies [7108d0b]
9
+ - @zhin.js/core@1.5.15
10
+ - zhin.js@6.0.15
11
+
12
+ ## 6.0.1
13
+
14
+ ### Patch Changes
15
+
16
+ - e9c6a73: Ignore late disconnect events from replaced IMAP transports so reconnect does not leak duplicate connections.
17
+ - 1fc78bc: Unify native platform Client access behind the literal `adapter` discriminant. Handlers infer both native events and Clients, while command, inbound/outbound middleware, and both Agent tool authoring surfaces expose the exact operation-scoped Client through a lazy `$client` getter. Definitions without `adapter` keep `$client` typed as `unknown`, and runtime dispatch rejects adapter mismatches before resolving the Client. Bundled platform tools now use this single path instead of model-provided endpoint ids and adapter-specific dependency wrappers. Every adapter registers one Client/EventMap contract, and protocol adapters including NapCat, Milky, OneBot and Satori now produce transport-independent Client objects rather than letting Endpoint instances impersonate Clients.
18
+ - Updated dependencies [4e8117c]
19
+ - Updated dependencies [54bfd6b]
20
+ - Updated dependencies [12025ee]
21
+ - Updated dependencies [09b14d6]
22
+ - Updated dependencies [1fc78bc]
23
+ - @zhin.js/adapter@1.2.1
24
+ - @zhin.js/core@1.5.14
25
+ - @zhin.js/logger@1.0.77
26
+ - zhin.js@6.0.14
27
+ - @zhin.js/feature-kit@1.0.13
28
+
3
29
  ## 6.0.0
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -19,7 +19,7 @@ pnpm add @zhin.js/adapter-email
19
19
  ## Plugin Runtime
20
20
 
21
21
  - `@zhin.js/adapter` — 约定式 `adapters/email.ts`(`defineAdapter`)
22
- - `@zhin.js/core` — `messageGatewayToken` 入站/出站
22
+ - `@zhin.js/core` — `Endpoint.emit(...)` 入站、`outboundMessageToken` 出站
23
23
  - `zhin.js` — `plugin.ts`(`definePlugin`)
24
24
  - 配置经插件 `schema.json` 落到 `plugins.<instanceKey>`(`smtp` / `imap`)
25
25
 
package/adapters/email.js CHANGED
@@ -3,7 +3,6 @@
3
3
  * Convention entry: discover `adapters/email.ts` → defineAdapter.
4
4
  */
5
5
  import { defineAdapter } from 'zhin.js/adapter';
6
- import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
7
6
  import { EmailEndpoint } from "../lib/endpoint.js";
8
7
  import { resolveEmailConfig, } from "../lib/protocol.js";
9
8
  export { EmailEndpoint } from "../lib/endpoint.js";
@@ -20,8 +19,6 @@ export default defineAdapter({
20
19
  create(context) {
21
20
  return new EmailEndpoint({
22
21
  id: context.id,
23
- gateway: context.use(messageGatewayToken),
24
- sideEvents: context.use(sideEventGatewayToken),
25
22
  config: resolveEmailConfig(context.config),
26
23
  });
27
24
  },
package/adapters/email.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  * Convention entry: discover `adapters/email.ts` → defineAdapter.
3
3
  */
4
4
  import { defineAdapter } from 'zhin.js/adapter';
5
- import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
6
5
  import { EmailEndpoint } from '../src/endpoint.js';
7
6
  import {
8
7
  resolveEmailConfig,
@@ -30,8 +29,6 @@ export default defineAdapter<EmailAdapterConfig>({
30
29
  create(context) {
31
30
  return new EmailEndpoint({
32
31
  id: context.id,
33
- gateway: context.use(messageGatewayToken),
34
- sideEvents: context.use(sideEventGatewayToken),
35
32
  config: resolveEmailConfig(context.config),
36
33
  });
37
34
  },
@@ -0,0 +1,23 @@
1
+ import type { EmailImapTransport, EmailSmtpTransport } from './transport.js';
2
+ /** Live SMTP + IMAP client pair exposed to event handlers and plugins. */
3
+ export declare class EmailClient {
4
+ private readonly resolveSmtp;
5
+ private readonly resolveImap;
6
+ constructor(resolveSmtp: () => EmailSmtpTransport | null, resolveImap: () => EmailImapTransport | null);
7
+ get smtp(): EmailSmtpTransport;
8
+ get imap(): EmailImapTransport;
9
+ verify(): Promise<void>;
10
+ sendMail(options: unknown): Promise<{
11
+ messageId?: string;
12
+ }>;
13
+ }
14
+ export type EmailClientEventMap = Record<string, unknown>;
15
+ declare module '@zhin.js/feature-kit' {
16
+ interface AdapterClientRegistry {
17
+ readonly email: {
18
+ readonly client: EmailClient;
19
+ readonly events: EmailClientEventMap;
20
+ };
21
+ }
22
+ }
23
+ export declare const emailClient: import("@zhin.js/adapter").EndpointClientToken<EmailClient, EmailClientEventMap>;
package/lib/client.js ADDED
@@ -0,0 +1,29 @@
1
+ import { defineEndpointClient } from 'zhin.js/adapter';
2
+ /** Live SMTP + IMAP client pair exposed to event handlers and plugins. */
3
+ export class EmailClient {
4
+ resolveSmtp;
5
+ resolveImap;
6
+ constructor(resolveSmtp, resolveImap) {
7
+ this.resolveSmtp = resolveSmtp;
8
+ this.resolveImap = resolveImap;
9
+ }
10
+ get smtp() {
11
+ const transport = this.resolveSmtp();
12
+ if (!transport)
13
+ throw new Error('SMTP transporter not connected');
14
+ return transport;
15
+ }
16
+ get imap() {
17
+ const transport = this.resolveImap();
18
+ if (!transport)
19
+ throw new Error('IMAP client not connected');
20
+ return transport;
21
+ }
22
+ verify() {
23
+ return this.smtp.verify();
24
+ }
25
+ sendMail(options) {
26
+ return this.smtp.sendMail(options);
27
+ }
28
+ }
29
+ export const emailClient = defineEndpointClient('email');
package/lib/endpoint.d.ts CHANGED
@@ -1,12 +1,11 @@
1
- import type { EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
2
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ import { type EndpointSendRequest } from 'zhin.js/adapter';
3
3
  import type { CapabilityId } from 'zhin.js';
4
4
  import { type EmailMessage, type ResolvedEmailConfig } from './protocol.js';
5
5
  import { type EmailImapTransport, type EmailSmtpTransport } from './transport.js';
6
+ import { EmailClient } from './client.js';
6
7
  export interface EmailEndpointOptions {
7
8
  readonly id: CapabilityId;
8
- readonly gateway: MessageGateway;
9
- readonly sideEvents?: SideEventGateway;
10
9
  readonly config: ResolvedEmailConfig;
11
10
  readonly createSmtp?: (config: ResolvedEmailConfig['smtp']) => EmailSmtpTransport | Promise<EmailSmtpTransport>;
12
11
  readonly createImap?: (config: ResolvedEmailConfig['imap']) => EmailImapTransport;
@@ -15,8 +14,9 @@ export interface EmailEndpointOptions {
15
14
  * Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
16
15
  * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
17
16
  */
18
- export declare class EmailEndpoint implements EndpointInstance {
17
+ export declare class EmailEndpoint extends Endpoint<EmailClient> {
19
18
  #private;
19
+ readonly client: EmailClient;
20
20
  constructor(options: EmailEndpointOptions);
21
21
  start(): Promise<void>;
22
22
  open(): void;
package/lib/endpoint.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
1
2
  /**
2
3
  * EmailEndpoint — lifecycle, SMTP outbound, IMAP inbound polling.
3
4
  */
@@ -7,11 +8,13 @@ import { simpleParser } from 'mailparser';
7
8
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
8
9
  import { emailInboundConversation, formatInboundContent, formatInboundSegments, formatOutboundMail, parseEmailMessage, senderDisplayName, } from './protocol.js';
9
10
  import { defaultCreateImap, defaultCreateSmtp, } from './transport.js';
11
+ import { EmailClient } from './client.js';
10
12
  /**
11
13
  * Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
12
14
  * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
13
15
  */
14
- export class EmailEndpoint {
16
+ export class EmailEndpoint extends Endpoint {
17
+ client;
15
18
  #logger;
16
19
  #options;
17
20
  #smtp = null;
@@ -23,8 +26,10 @@ export class EmailEndpoint {
23
26
  #open = false;
24
27
  #started = false;
25
28
  constructor(options) {
29
+ super();
26
30
  this.#logger = getAdapterLogger('email', options.config.id);
27
31
  this.#options = options;
32
+ this.client = new EmailClient(() => this.#smtp, () => this.#imap);
28
33
  }
29
34
  async start() {
30
35
  if (this.#started)
@@ -38,7 +43,10 @@ export class EmailEndpoint {
38
43
  this.#imap = this.#options.createImap?.(imap) ?? defaultCreateImap(imap);
39
44
  this.#setupImapListeners(this.#imap);
40
45
  await new Promise((resolve, reject) => {
41
- this.#imap.once('ready', () => resolve());
46
+ this.#imap.once('ready', () => {
47
+ void this.#emitPlatformEvent('imap.ready', Object.freeze({}));
48
+ resolve();
49
+ });
42
50
  this.#imap.once('error', (error) => reject(error));
43
51
  this.#imap.connect();
44
52
  });
@@ -91,14 +99,12 @@ export class EmailEndpoint {
91
99
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
92
100
  }
93
101
  async send({ conversation, payload }) {
94
- if (!this.#smtp)
95
- throw new Error('SMTP transporter not initialized');
96
102
  const target = conversation.id;
97
103
  const mailOptions = formatOutboundMail(payload, {
98
104
  from: this.#options.config.smtp.auth.user,
99
105
  to: target,
100
106
  });
101
- const info = await this.#smtp.sendMail(mailOptions);
107
+ const info = await this.client.sendMail(mailOptions);
102
108
  this.#logger.debug(formatCompact({ op: 'email_send', target, messageId: info.messageId }));
103
109
  return info.messageId || '';
104
110
  }
@@ -106,6 +112,7 @@ export class EmailEndpoint {
106
112
  admit(email) {
107
113
  if (!this.#open)
108
114
  return;
115
+ void this.#emitPlatformEvent('mail', email);
109
116
  void this.#admitWithAttachments(email).catch((err) => {
110
117
  this.#logger.warn(formatCompact({
111
118
  op: 'email_gateway_receive_failed',
@@ -119,7 +126,7 @@ export class EmailEndpoint {
119
126
  const content = formatInboundContent(email);
120
127
  const sender = email.from;
121
128
  const conversation = emailInboundConversation(String(this.#options.id), email);
122
- await this.#options.gateway.receive({
129
+ await this.emit('message.receive', {
123
130
  conversation,
124
131
  ...(email.messageId ? { message: { conversation, id: email.messageId } } : {}),
125
132
  content,
@@ -180,24 +187,39 @@ export class EmailEndpoint {
180
187
  }
181
188
  #setupImapListeners(imap) {
182
189
  imap.on('mail', () => {
190
+ void this.#emitPlatformEvent('imap.mail', Object.freeze({}));
183
191
  void this.#checkForNewEmails();
184
192
  });
185
193
  imap.on('error', (error) => {
194
+ void this.#emitPlatformEvent('imap.error', error);
186
195
  this.#logger.error('IMAP error:', error);
187
196
  // imap 通常在 error 后紧跟 end;两处都调度,靠已有定时器去重
188
- this.#scheduleImapReconnect();
197
+ this.#scheduleImapReconnect(imap);
189
198
  });
190
199
  imap.on('end', () => {
200
+ void this.#emitPlatformEvent('imap.end', Object.freeze({}));
191
201
  this.#logger.debug(formatCompact({
192
202
  op: 'disconnect',
193
203
  mode: 'imap',
194
204
  }));
195
- this.#scheduleImapReconnect();
205
+ this.#scheduleImapReconnect(imap);
206
+ });
207
+ }
208
+ async #emitPlatformEvent(name, event) {
209
+ await this.emitPlatform(name, event).catch((error) => {
210
+ this.#logger.warn(formatCompact({
211
+ op: 'email_platform_event_failed',
212
+ event: name,
213
+ error: error instanceof Error ? error.message : String(error),
214
+ }));
196
215
  });
197
216
  }
198
217
  /** IMAP 断线后按指数退避重建连接并恢复监听(基数 reconnectInterval,封顶 5 分钟)。 */
199
- #scheduleImapReconnect() {
200
- if (!this.#started || this.#reconnectTimer)
218
+ #scheduleImapReconnect(source) {
219
+ // A replaced transport may emit a late `end` after its earlier `error`
220
+ // already caused a successful reconnect. Only the currently owned IMAP
221
+ // connection may arm the next generation's reconnect timer.
222
+ if (!this.#started || this.#reconnectTimer || (source && this.#imap !== source))
201
223
  return;
202
224
  const base = this.#options.config.imap.reconnectInterval;
203
225
  const delay = Math.min(base * 2 ** this.#reconnectAttempts, 300_000);
@@ -215,15 +237,20 @@ export class EmailEndpoint {
215
237
  async #reconnectImap() {
216
238
  if (!this.#started)
217
239
  return;
240
+ let imap;
218
241
  try {
219
- const imap = this.#options.createImap?.(this.#options.config.imap)
242
+ const nextImap = this.#options.createImap?.(this.#options.config.imap)
220
243
  ?? defaultCreateImap(this.#options.config.imap);
221
- this.#imap = imap;
222
- this.#setupImapListeners(imap);
244
+ imap = nextImap;
245
+ this.#imap = nextImap;
246
+ this.#setupImapListeners(nextImap);
223
247
  await new Promise((resolve, reject) => {
224
- imap.once('ready', () => resolve());
225
- imap.once('error', (error) => reject(error));
226
- imap.connect();
248
+ nextImap.once('ready', () => {
249
+ void this.#emitPlatformEvent('imap.ready', Object.freeze({ reconnect: true }));
250
+ resolve();
251
+ });
252
+ nextImap.once('error', (error) => reject(error));
253
+ nextImap.connect();
227
254
  });
228
255
  this.#reconnectAttempts = 0;
229
256
  this.#logger.info(formatCompact({
@@ -240,7 +267,7 @@ export class EmailEndpoint {
240
267
  ok: false,
241
268
  error: error instanceof Error ? error.message : String(error),
242
269
  }));
243
- this.#scheduleImapReconnect();
270
+ this.#scheduleImapReconnect(imap);
244
271
  }
245
272
  }
246
273
  #startEmailCheck() {
package/lib/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
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
+ export { EmailClient, emailClient, type EmailClientEventMap, } from './client.js';
2
3
  export { EmailEndpoint, type EmailEndpointOptions, } from './endpoint.js';
3
4
  export { defaultCreateImap, defaultCreateSmtp, type EmailImapFetchMessage, type EmailImapTransport, type EmailSmtpTransport, } from './transport.js';
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { addressListText, emailInboundConversation, formatInboundContent, formatInboundSegments, formatOutboundMail, htmlToText, parseEmailMessage, resolveEmailConfig, senderDisplayName, } from './protocol.js';
2
+ export { EmailClient, emailClient, } from './client.js';
2
3
  export { EmailEndpoint, } from './endpoint.js';
3
4
  export { defaultCreateImap, defaultCreateSmtp, } from './transport.js';
package/lib/protocol.d.ts CHANGED
@@ -87,7 +87,7 @@ export declare function parseEmailMessage(parsed: {
87
87
  attachments?: Attachment[];
88
88
  date?: Date;
89
89
  }, uid: number): EmailMessage;
90
- /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
90
+ /** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
91
91
  export declare function formatInboundContent(email: EmailMessage): string;
92
92
  /** 已落盘的入站附件(attachments.enabled 下载结果)。 */
93
93
  export interface SavedEmailAttachment {
package/lib/protocol.js CHANGED
@@ -72,7 +72,7 @@ export function parseEmailMessage(parsed, uid) {
72
72
  uid,
73
73
  };
74
74
  }
75
- /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
75
+ /** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
76
76
  export function formatInboundContent(email) {
77
77
  const parts = [];
78
78
  if (email.subject)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-email",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
4
4
  "type": "module",
5
5
  "description": "Zhin.js Email adapter for Plugin Runtime (SMTP/IMAP)",
6
6
  "main": "./lib/index.js",
@@ -9,15 +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.2.0",
13
- "@zhin.js/core": "1.5.13",
12
+ "@zhin.js/adapter": "1.2.1",
13
+ "@zhin.js/core": "1.5.15",
14
+ "@zhin.js/feature-kit": "1.0.13",
14
15
  "@zhin.js/im-contract": "1.0.4",
15
- "@zhin.js/logger": "1.0.76"
16
+ "@zhin.js/logger": "1.0.77"
16
17
  },
17
18
  "peerDependencies": {
18
- "@zhin.js/adapter": "1.2.0",
19
- "@zhin.js/core": "1.5.13",
20
- "zhin.js": "6.0.13"
19
+ "@zhin.js/adapter": "1.2.1",
20
+ "@zhin.js/core": "1.5.15",
21
+ "zhin.js": "6.0.15"
21
22
  },
22
23
  "peerDependenciesMeta": {
23
24
  "zhin.js": {
@@ -31,7 +32,7 @@
31
32
  "@types/nodemailer": "^8.0.1",
32
33
  "typescript": "^6.0.3",
33
34
  "vitest": "^4.1.10",
34
- "zhin.js": "6.0.13"
35
+ "zhin.js": "6.0.15"
35
36
  },
36
37
  "keywords": [
37
38
  "zhin",
package/src/client.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { EmailImapTransport, EmailSmtpTransport } from './transport.js';
2
+ import { defineEndpointClient } from 'zhin.js/adapter';
3
+
4
+ /** Live SMTP + IMAP client pair exposed to event handlers and plugins. */
5
+ export class EmailClient {
6
+ constructor(
7
+ private readonly resolveSmtp: () => EmailSmtpTransport | null,
8
+ private readonly resolveImap: () => EmailImapTransport | null,
9
+ ) {}
10
+
11
+ get smtp(): EmailSmtpTransport {
12
+ const transport = this.resolveSmtp();
13
+ if (!transport) throw new Error('SMTP transporter not connected');
14
+ return transport;
15
+ }
16
+
17
+ get imap(): EmailImapTransport {
18
+ const transport = this.resolveImap();
19
+ if (!transport) throw new Error('IMAP client not connected');
20
+ return transport;
21
+ }
22
+
23
+ verify(): Promise<void> {
24
+ return this.smtp.verify();
25
+ }
26
+
27
+ sendMail(options: unknown): Promise<{ messageId?: string }> {
28
+ return this.smtp.sendMail(options);
29
+ }
30
+ }
31
+
32
+ export type EmailClientEventMap = Record<string, unknown>;
33
+
34
+ declare module '@zhin.js/feature-kit' {
35
+ interface AdapterClientRegistry {
36
+ readonly email: { readonly client: EmailClient; readonly events: EmailClientEventMap };
37
+ }
38
+ }
39
+
40
+ export const emailClient = defineEndpointClient<EmailClient, EmailClientEventMap>('email');
package/src/endpoint.ts CHANGED
@@ -1,11 +1,11 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
1
2
  /**
2
3
  * EmailEndpoint — lifecycle, SMTP outbound, IMAP inbound polling.
3
4
  */
4
5
  import { mkdir, writeFile } from 'node:fs/promises';
5
6
  import * as path from 'node:path';
6
7
  import { simpleParser } from 'mailparser';
7
- import type { EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
8
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
8
+ import { type EndpointSendRequest } from 'zhin.js/adapter';
9
9
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
10
10
  import type { CapabilityId } from 'zhin.js';
11
11
  import {
@@ -26,11 +26,10 @@ import {
26
26
  type EmailImapTransport,
27
27
  type EmailSmtpTransport,
28
28
  } from './transport.js';
29
+ import { EmailClient } from './client.js';
29
30
 
30
31
  export interface EmailEndpointOptions {
31
32
  readonly id: CapabilityId;
32
- readonly gateway: MessageGateway;
33
- readonly sideEvents?: SideEventGateway;
34
33
  readonly config: ResolvedEmailConfig;
35
34
  readonly createSmtp?: (config: ResolvedEmailConfig['smtp']) => EmailSmtpTransport | Promise<EmailSmtpTransport>;
36
35
  readonly createImap?: (config: ResolvedEmailConfig['imap']) => EmailImapTransport;
@@ -40,7 +39,8 @@ export interface EmailEndpointOptions {
40
39
  * Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
41
40
  * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
42
41
  */
43
- export class EmailEndpoint implements EndpointInstance {
42
+ export class EmailEndpoint extends Endpoint<EmailClient> {
43
+ readonly client: EmailClient;
44
44
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
45
45
 
46
46
  readonly #options: EmailEndpointOptions;
@@ -54,8 +54,10 @@ export class EmailEndpoint implements EndpointInstance {
54
54
  #started = false;
55
55
 
56
56
  constructor(options: EmailEndpointOptions) {
57
+ super();
57
58
  this.#logger = getAdapterLogger('email', options.config.id);
58
59
  this.#options = options;
60
+ this.client = new EmailClient(() => this.#smtp, () => this.#imap);
59
61
  }
60
62
 
61
63
  async start(): Promise<void> {
@@ -70,7 +72,10 @@ export class EmailEndpoint implements EndpointInstance {
70
72
  this.#imap = this.#options.createImap?.(imap) ?? defaultCreateImap(imap);
71
73
  this.#setupImapListeners(this.#imap);
72
74
  await new Promise<void>((resolve, reject) => {
73
- this.#imap!.once('ready', () => resolve());
75
+ this.#imap!.once('ready', () => {
76
+ void this.#emitPlatformEvent('imap.ready', Object.freeze({}));
77
+ resolve();
78
+ });
74
79
  this.#imap!.once('error', (error) => reject(error));
75
80
  this.#imap!.connect();
76
81
  });
@@ -124,13 +129,12 @@ export class EmailEndpoint implements EndpointInstance {
124
129
  }
125
130
 
126
131
  async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
127
- if (!this.#smtp) throw new Error('SMTP transporter not initialized');
128
132
  const target = conversation.id;
129
133
  const mailOptions = formatOutboundMail(payload, {
130
134
  from: this.#options.config.smtp.auth.user,
131
135
  to: target,
132
136
  });
133
- const info = await this.#smtp.sendMail(mailOptions);
137
+ const info = await this.client.sendMail(mailOptions);
134
138
  this.#logger.debug(formatCompact({ op: 'email_send', target, messageId: info.messageId }));
135
139
  return info.messageId || '';
136
140
  }
@@ -138,6 +142,7 @@ export class EmailEndpoint implements EndpointInstance {
138
142
  /** Test / internal: admit a parsed mail when the endpoint is open. */
139
143
  admit(email: EmailMessage): void {
140
144
  if (!this.#open) return;
145
+ void this.#emitPlatformEvent('mail', email);
141
146
  void this.#admitWithAttachments(email).catch((err) => {
142
147
  this.#logger.warn(formatCompact({
143
148
  op: 'email_gateway_receive_failed',
@@ -152,7 +157,7 @@ export class EmailEndpoint implements EndpointInstance {
152
157
  const content = formatInboundContent(email);
153
158
  const sender = email.from;
154
159
  const conversation = emailInboundConversation(String(this.#options.id), email);
155
- await this.#options.gateway.receive({
160
+ await this.emit('message.receive', {
156
161
  conversation,
157
162
  ...(email.messageId ? { message: { conversation, id: email.messageId } } : {}),
158
163
  content,
@@ -215,25 +220,41 @@ export class EmailEndpoint implements EndpointInstance {
215
220
 
216
221
  #setupImapListeners(imap: EmailImapTransport): void {
217
222
  imap.on('mail', () => {
223
+ void this.#emitPlatformEvent('imap.mail', Object.freeze({}));
218
224
  void this.#checkForNewEmails();
219
225
  });
220
226
  imap.on('error', (error) => {
227
+ void this.#emitPlatformEvent('imap.error', error);
221
228
  this.#logger.error('IMAP error:', error);
222
229
  // imap 通常在 error 后紧跟 end;两处都调度,靠已有定时器去重
223
- this.#scheduleImapReconnect();
230
+ this.#scheduleImapReconnect(imap);
224
231
  });
225
232
  imap.on('end', () => {
233
+ void this.#emitPlatformEvent('imap.end', Object.freeze({}));
226
234
  this.#logger.debug(formatCompact({
227
235
  op: 'disconnect',
228
236
  mode: 'imap',
229
237
  }));
230
- this.#scheduleImapReconnect();
238
+ this.#scheduleImapReconnect(imap);
239
+ });
240
+ }
241
+
242
+ async #emitPlatformEvent(name: string, event: unknown): Promise<void> {
243
+ await this.emitPlatform(name, event).catch((error) => {
244
+ this.#logger.warn(formatCompact({
245
+ op: 'email_platform_event_failed',
246
+ event: name,
247
+ error: error instanceof Error ? error.message : String(error),
248
+ }));
231
249
  });
232
250
  }
233
251
 
234
252
  /** IMAP 断线后按指数退避重建连接并恢复监听(基数 reconnectInterval,封顶 5 分钟)。 */
235
- #scheduleImapReconnect(): void {
236
- if (!this.#started || this.#reconnectTimer) return;
253
+ #scheduleImapReconnect(source?: EmailImapTransport): void {
254
+ // A replaced transport may emit a late `end` after its earlier `error`
255
+ // already caused a successful reconnect. Only the currently owned IMAP
256
+ // connection may arm the next generation's reconnect timer.
257
+ if (!this.#started || this.#reconnectTimer || (source && this.#imap !== source)) return;
237
258
  const base = this.#options.config.imap.reconnectInterval;
238
259
  const delay = Math.min(base * 2 ** this.#reconnectAttempts, 300_000);
239
260
  this.#reconnectAttempts += 1;
@@ -250,15 +271,20 @@ export class EmailEndpoint implements EndpointInstance {
250
271
 
251
272
  async #reconnectImap(): Promise<void> {
252
273
  if (!this.#started) return;
274
+ let imap: EmailImapTransport | undefined;
253
275
  try {
254
- const imap = this.#options.createImap?.(this.#options.config.imap)
276
+ const nextImap = this.#options.createImap?.(this.#options.config.imap)
255
277
  ?? defaultCreateImap(this.#options.config.imap);
256
- this.#imap = imap;
257
- this.#setupImapListeners(imap);
278
+ imap = nextImap;
279
+ this.#imap = nextImap;
280
+ this.#setupImapListeners(nextImap);
258
281
  await new Promise<void>((resolve, reject) => {
259
- imap.once('ready', () => resolve());
260
- imap.once('error', (error) => reject(error));
261
- imap.connect();
282
+ nextImap.once('ready', () => {
283
+ void this.#emitPlatformEvent('imap.ready', Object.freeze({ reconnect: true }));
284
+ resolve();
285
+ });
286
+ nextImap.once('error', (error) => reject(error));
287
+ nextImap.connect();
262
288
  });
263
289
  this.#reconnectAttempts = 0;
264
290
  this.#logger.info(formatCompact({
@@ -274,7 +300,7 @@ export class EmailEndpoint implements EndpointInstance {
274
300
  ok: false,
275
301
  error: error instanceof Error ? error.message : String(error),
276
302
  }));
277
- this.#scheduleImapReconnect();
303
+ this.#scheduleImapReconnect(imap);
278
304
  }
279
305
  }
280
306
 
package/src/index.ts CHANGED
@@ -18,6 +18,12 @@ export {
18
18
  type SmtpConfig,
19
19
  } from './protocol.js';
20
20
 
21
+ export {
22
+ EmailClient,
23
+ emailClient,
24
+ type EmailClientEventMap,
25
+ } from './client.js';
26
+
21
27
  export {
22
28
  EmailEndpoint,
23
29
  type EmailEndpointOptions,
package/src/protocol.ts CHANGED
@@ -171,7 +171,7 @@ export function parseEmailMessage(
171
171
  };
172
172
  }
173
173
 
174
- /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
174
+ /** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
175
175
  export function formatInboundContent(email: EmailMessage): string {
176
176
  const parts: string[] = [];
177
177
  if (email.subject) parts.push(`Subject: ${email.subject}`, '');